Compare commits

...

8 Commits

Author SHA1 Message Date
renovate[bot] 5c17edb1ed Update Playwright 2026-07-27 23:14:13 +00:00
Aidan Timson 669eab4d31 Hide superseded E2E failure comments (#53331) 2026-07-27 17:21:14 +03:00
Timothy ea179bd7fe Limit and avoid duplication when loaded is sent to mobile (#53335) 2026-07-27 17:08:12 +03:00
karwosts ef50cc0332 Abbreviate sections in automation add search dialog (#53286)
* Abbreviate sections in automation add search dialog

* fixes

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-27 13:59:43 +00:00
Aidan Timson ab0ce2a3b7 Keep Playwright Renovate updates together (#53336) 2026-07-27 13:54:53 +00:00
Petar Petrov 23bc2cf1c1 Share the sankey effective-parent walk across all four cards (#53329)
* Share the sankey effective-parent walk across all four cards

Each of the four sankey cards built its own `findEffectiveParent` to walk
`included_in_stat` up to the nearest rendered ancestor. The two cumulative
cards were byte-identical; the two instantaneous ones differed only in
comments and brace style. The pairs differed from each other only in which
id space they resolve to, which is exactly what `getId` already expresses.

Move the walk into `buildSankeyDeviceNodes`, which already receives
`devices`, `getId`, `getValue` and `minThreshold`, and drop the
`findEffectiveParent` option. A `renderedId` helper maps a stat_consumption
through `getId` to a node id, so one implementation covers both spaces.

Also folds in three incidental improvements:

- `deviceByStat` replaces each card's `deviceMap` plus the `deviceParents`
  map added in #53324.
- The walk is a bounded `for` instead of an unbounded `while`, which would
  hang on a cyclic `included_in_stat`. Cycles are prevented in the config UI
  but not validated in core.
- `getValue` is now called once per device instead of twice. For the
  cumulative cards it is `calculateStatisticSumGrowth`, which is O(points).

No behaviour change. Adds coverage for the instantaneous id space, which
had no hierarchy tests, and for a nested small consumer inside a rendered
parent's cluster.

* Keep small sankey devices attributed under a rendered ancestor

The nested-small-consumer filter from #53324 walks `included_in_stat` and
drops any small consumer whose chain reaches another small consumer. The
walk did not stop at ancestors that are rendered as their own node, so a
small device sitting under a rendered parent was dropped whenever anything
further up the chain happened to be small.

Such a consumer links to its rendered parent and never touches untracked,
so it cannot be double-counted through a small ancestor beyond it - and
dropping it does not fix the separate overlap between the rendered node and
that ancestor. It just loses a real device from the graph.

Stop the walk at the first rendered ancestor. Needs `included_in_stat` data
where a parent statistic reads smaller than its child, so it takes a gap or
a late start on the parent to trigger.
2026-07-27 14:59:02 +02:00
Marcin Bauer 85e94841a9 Improve target picker group header (#53250)
* Improve target picker group header

- Show group item count only when the group is collapsed, styled as a
  secondary-color "(N)" next to the type label
- Thin the item-groups border to 1px via --ha-border-width-sm
- Remove the "Targets" pre-label before the target selector in the
  action, trigger and condition editors, moving its spacing/divider onto
  the selector itself

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Update src/components/target-picker/ha-target-picker-item-group.ts

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-27 14:37:34 +02:00
Paulus Schoutsen 1de7230d9e Fall back to English when nightly translations cannot be fetched (#53272)
* Fall back to English when nightly translations cannot be fetched

Local builds no longer fail when the nightly translations fetch errors
(offline or invalid GitHub credentials); they warn and continue with
English only, like the existing no-token path. CI still fails so
releases cannot silently ship without translations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Record fetched artifact only after successful extraction

A failure mid-fetch previously left a fresh artifact.json next to wiped
translations, so builds within the next 24 hours skipped the fetch and
silently built English only. Writing the artifact file last means a
failed fetch is retried by the next build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3oj5uaCRRaoXRd5iEffhi

* Update build-scripts/gulp/fetch-nightly-translations.js

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-27 11:32:26 +00:00
21 changed files with 472 additions and 311 deletions
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001
defaults:
run:
@@ -154,7 +154,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -206,7 +206,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -260,7 +260,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -52,6 +52,23 @@ gulp.task("fetch-nightly-translations", async function () {
currentArtifact = null;
}
try {
await fetchTranslations(currentArtifact);
} catch (err) {
// Local builds should work offline or without valid GitHub credentials,
// so fall back to English only. CI must fail instead of silently
// building without translations.
if (process.env.CI) {
throw err;
}
console.warn(
"Failed to fetch nightly translations, continuing with English only:",
err?.message || err
);
}
});
async function fetchTranslations(currentArtifact) {
// To store file writing promises
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
const writings = [];
@@ -130,11 +147,6 @@ gulp.task("fetch-nightly-translations", async function () {
if (!latestArtifact) {
throw Error("Latest nightly workflow run has no translations artifact");
}
writings.push(
createExtractDir.then(
writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2))
)
);
// Remove the current translations
const deleteCurrent = Promise.all(writings).then(
@@ -160,7 +172,12 @@ gulp.task("fetch-nightly-translations", async function () {
await new Promise((resolve, reject) => {
extractStream.on("close", resolve).on("error", reject);
});
});
// Record the artifact only after successful extraction, so a failed fetch
// is retried by the next build instead of being considered current.
await createExtractDir;
await writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2));
}
gulp.task(
"setup-and-fetch-nightly-translations",
+1 -1
View File
@@ -150,7 +150,7 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@playwright/test": "1.62.0",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.5",
"@rspack/dev-server": "2.1.0",
+2 -1
View File
@@ -106,7 +106,8 @@
{
"description": "Group Playwright package and CI container updates",
"groupName": "Playwright",
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
"minimumGroupSize": 5
}
]
}
+16 -14
View File
@@ -542,20 +542,14 @@ export class HaServiceControl extends LitElement {
}
${
serviceData && "target" in serviceData
? html`<ha-settings-row
.narrow=${this.narrow || isFullWidthSelector(targetSelector)}
>
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<ha-selector
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>`
: entityId
? html`<ha-entity-picker
.disabled=${this.disabled}
@@ -1059,6 +1053,14 @@ export class HaServiceControl extends LitElement {
display: block;
margin: var(--service-control-padding, 0 var(--ha-space-4));
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
+1 -1
View File
@@ -1342,7 +1342,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
.item-groups {
overflow: hidden;
border: 2px solid var(--divider-color);
border: var(--ha-border-width-sm) solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
@@ -8,6 +8,13 @@ import "../ha-expansion-panel";
import "../list/ha-list-base";
import "./ha-target-picker-item-row";
const TYPE_PLURAL = {
entity: "entities",
device: "devices",
area: "areas",
label: "labels",
} as const satisfies Record<TargetTypeFloorless, string>;
@customElement("ha-target-picker-item-group")
export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -60,11 +67,11 @@ export class HaTargetPickerItemGroup extends LitElement {
>
<div slot="header" class="heading">
${this.hass.localize(
`ui.components.target-picker.selected.${this.type}`,
{
count,
}
`ui.components.target-picker.type.${TYPE_PLURAL[this.type]}`
)}
${
this.collapsed ? html`<span class="count">(${count})</span>` : nothing
}
</div>
<ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
@@ -106,6 +113,10 @@ export class HaTargetPickerItemGroup extends LitElement {
justify-content: space-between;
min-height: unset;
}
.count {
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
`;
}
+7 -4
View File
@@ -135,11 +135,14 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
this.update = super.update;
// Apps with a native splash screen keep covering the frontend until
// frontend/loaded, so the launch screen stays up (invisibly) until the
// first panel has rendered and partial-panel-resolver removes it.
if (!this.hass.auth.external?.config.hasSplashscreen) {
removeLaunchScreen();
// first panel has rendered and partial-panel-resolver removes it and
// fires frontend/loaded.
if (
!this.hass.auth.external?.config.hasSplashscreen &&
removeLaunchScreen()
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
super.update(changedProps);
}
+7 -2
View File
@@ -220,8 +220,13 @@ class PartialPanelResolver extends HassRouterPage {
) {
await this.rebuild();
await this.pageRendered;
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen);
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again.
if (
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
}
}
}
@@ -1,7 +1,7 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { consume } from "@lit/context";
import "@material/mwc-list/mwc-list";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import { mdiPlus, mdiTextureBox, mdiUnfoldMoreHorizontal } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import {
@@ -98,8 +98,16 @@ export const ITEM_SEARCH_KEYS: FuseWeightedKey[] = [
},
];
const MAX_SEARCH_ITEMS_PER_SECTION = 5;
type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label";
interface SearchMoreComboBoxItem extends PickerComboBoxItem {
type: "more";
section: "entity" | "device" | "item";
label: string;
}
@customElement("ha-automation-add-search")
export class HaAutomationAddSearch extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -288,7 +296,8 @@ export class HaAutomationAddSearch extends LitElement {
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem
| AutomationItemComboBoxItem,
| AutomationItemComboBoxItem
| SearchMoreComboBoxItem,
index: number
) => {
if (!item) {
@@ -299,6 +308,24 @@ export class HaAutomationAddSearch extends LitElement {
return html`<ha-section-title>${item}</ha-section-title>`;
}
if ("type" in item && item.type === "more") {
return html`<ha-combo-box-item
id=${`search-list-item-${index}`}
type="button"
tabindex="-1"
.section-id=${item.section}
.value=${`more-${item.section}`}
@click=${this._toggleSection}
>
<ha-svg-icon
slot="start"
.path=${mdiUnfoldMoreHorizontal}
></ha-svg-icon>
<span slot="headline"></span>
<span slot="supporting-text">${item.label}</span>
</ha-combo-box-item>`;
}
const type = ["trigger", "condition", "action", "block"].includes(
(item as AutomationItemComboBoxItem).type
)
@@ -512,10 +539,17 @@ export class HaAutomationAddSearch extends LitElement {
selectedSection?: SearchSection,
relatedIdSets?: RelatedIdSets
) => {
const resultItems: (
string | FloorComboBoxItem | EntityComboBoxItem | PickerComboBoxItem
)[] = [];
type ResultItem =
| string
| FloorComboBoxItem
| EntityComboBoxItem
| PickerComboBoxItem
| SearchMoreComboBoxItem;
const resultSections: {
title: string;
type: string;
items: ResultItem[];
}[] = [];
if (!selectedSection || selectedSection === "item") {
let items = this._convertItemsToComboBoxItems(automationItems, type);
if (searchTerm) {
@@ -526,15 +560,13 @@ export class HaAutomationAddSearch extends LitElement {
ITEM_SEARCH_KEYS
) as AutomationItemComboBoxItem[];
}
if (!selectedSection && items.length) {
// show group title
resultItems.push(
localize(`ui.panel.config.automation.editor.${type}s.name`)
);
if (items.length) {
resultSections.push({
title: localize(`ui.panel.config.automation.editor.${type}s.name`),
type: "item",
items: items,
});
}
resultItems.push(...items);
}
if (
@@ -564,13 +596,13 @@ export class HaAutomationAddSearch extends LitElement {
) as AutomationItemComboBoxItem[];
}
if (!selectedSection && blocks.length) {
// show group title
resultItems.push(
localize("ui.panel.config.automation.editor.blocks")
);
if (blocks.length) {
resultSections.push({
title: localize("ui.panel.config.automation.editor.blocks"),
type: "block",
items: blocks,
});
}
resultItems.push(...blocks);
}
if (!selectedSection || selectedSection === "entity") {
@@ -601,14 +633,13 @@ export class HaAutomationAddSearch extends LitElement {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
if (entityItems.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.entities"),
type: "entity",
items: entityItems,
});
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
@@ -640,14 +671,13 @@ export class HaAutomationAddSearch extends LitElement {
deviceItems = sortRelatedFirst(deviceItems);
}
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
if (deviceItems.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.devices"),
type: "device",
items: deviceItems,
});
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
@@ -697,15 +727,9 @@ export class HaAutomationAddSearch extends LitElement {
) as FloorComboBoxItem[];
}
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.areas"));
}
resultItems.push(
...areasAndFloors.map((item, index) => {
if (areasAndFloors.length) {
const areaItems = areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
@@ -715,10 +739,15 @@ export class HaAutomationAddSearch extends LitElement {
last: true,
};
}
return item;
})
);
});
resultSections.push({
title: localize("ui.components.target-picker.type.areas"),
type: "area",
items: areaItems,
});
}
}
if (!selectedSection || selectedSection === "label") {
@@ -746,14 +775,48 @@ export class HaAutomationAddSearch extends LitElement {
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.labels"));
if (labels.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.labels"),
type: "label",
items: labels,
});
}
resultItems.push(...labels);
}
const resultItems: ResultItem[] = [];
resultSections.forEach((section, index) => {
if (selectedSection) {
resultItems.push(...section.items);
return;
}
resultItems.push(section.title);
if (
index !== resultSections.length - 1 &&
(section.type === "item" ||
section.type === "entity" ||
section.type === "device") &&
section.items.length > MAX_SEARCH_ITEMS_PER_SECTION + 1
) {
resultItems.push(
...section.items.slice(0, MAX_SEARCH_ITEMS_PER_SECTION)
);
const typeKey = section.type === "item" ? type : section.type;
resultItems.push({
primary: "",
id: `search-more-${section.type}`,
type: "more",
section: section.type,
label: localize(
`ui.panel.config.automation.editor.show_more_search.${typeKey}`,
{ count: section.items.length - MAX_SEARCH_ITEMS_PER_SECTION }
),
});
} else {
resultItems.push(...section.items);
}
});
return resultItems;
}
);
@@ -790,18 +853,22 @@ export class HaAutomationAddSearch extends LitElement {
);
}
private _toggleSection(ev: Event) {
private _toggleSection = (ev: Event) => {
ev.stopPropagation();
// this._resetSelectedItem();
this._searchSectionTitle = undefined;
const section = (ev.target as HTMLElement)["section-id"] as string;
const section = (ev.currentTarget as HTMLElement)["section-id"] as string;
if (!section) {
return;
}
if (this._selectedSearchSection === section) {
this._toggleSectionType(section);
};
private _toggleSectionType(type: string) {
if (this._selectedSearchSection === type) {
this._selectedSearchSection = undefined;
} else {
this._selectedSearchSection = section as SearchSection;
this._selectedSearchSection = type as SearchSection;
}
// Reset scroll position when filter changes
@@ -1019,9 +1086,13 @@ export class HaAutomationAddSearch extends LitElement {
const item = this._virtualizerElement?.items[
this._selectedSearchItemIndex
] as PickerComboBoxItem;
] as PickerComboBoxItem | SearchMoreComboBoxItem;
if (item) {
this._selectSearchItem(item);
if ("type" in item && item.type === "more") {
this._toggleSectionType((item as SearchMoreComboBoxItem).section);
} else {
this._selectSearchItem(item as PickerComboBoxItem);
}
}
};
@@ -218,20 +218,14 @@ export class HaPlatformCondition extends LitElement {
</div>
${
conditionDesc && "target" in conditionDesc
? html`<ha-settings-row narrow>
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(conditionDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.condition?.target}
></ha-selector
></ha-settings-row>`
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${this._targetSelector(conditionDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.condition?.target}
></ha-selector>`
: nothing
}
${
@@ -670,6 +664,14 @@ export class HaPlatformCondition extends LitElement {
display: block;
margin: 0 var(--ha-space-4);
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -213,19 +213,14 @@ export class HaPlatformTrigger extends LitElement {
</div>
${
triggerDesc && "target" in triggerDesc
? html`<ha-settings-row narrow>
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(triggerDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.trigger?.target}
></ha-selector
></ha-settings-row>`
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${this._targetSelector(triggerDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.trigger?.target}
></ha-selector>`
: nothing
}
${
@@ -548,6 +543,14 @@ export class HaPlatformTrigger extends LitElement {
display: block;
margin: 0 var(--ha-space-4);
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -54,9 +54,6 @@ export interface BuildSankeyDeviceNodesOptions {
getValue: (id: string) => number;
getLabel: (id: string, name: string | undefined) => string;
getEntityId: (id: string) => string | undefined;
findEffectiveParent: (
includedInStat: string | undefined
) => string | undefined;
}
/**
@@ -86,7 +83,6 @@ export const buildSankeyDeviceNodes = (
getValue,
getLabel,
getEntityId,
findEffectiveParent,
} = options;
const unavailableColor = computedStyle
@@ -100,19 +96,62 @@ export const buildSankeyDeviceNodes = (
const smallConsumerStats = new Set<string>();
let untrackedConsumption = initialUntracked;
// Parent chain in stat_consumption space, used to detect nested small consumers
const deviceParents = new Map<string, string | undefined>();
// Resolve every device's node id and value once. `included_in_stat` always
// names a stat_consumption, so the hierarchy is keyed by that, while the
// rendered set holds node ids (stat_consumption or stat_rate, per `getId`).
const deviceByStat = new Map<string, DeviceConsumptionEnergyPreference>();
const deviceValues = new Map<string, number>();
const renderedIds = new Set<string>();
devices.forEach((device) => {
deviceParents.set(device.stat_consumption, device.included_in_stat);
});
devices.forEach((device, idx) => {
deviceByStat.set(device.stat_consumption, device);
const id = getId(device);
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
if (!id) {
return;
}
const value = getValue(id);
deviceValues.set(id, value);
if (value >= minThreshold) {
renderedIds.add(id);
}
});
/** Node id of a device rendered as its own node, else undefined. */
const renderedId = (statConsumption: string): string | undefined => {
const device = deviceByStat.get(statConsumption);
if (!device) {
return undefined;
}
const id = getId(device);
return id && renderedIds.has(id) ? id : undefined;
};
// Walk up the included_in_stat chain to the first ancestor that is rendered.
// Bounded because a hand-edited config can make included_in_stat cyclic.
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let current = includedInStat;
for (let hops = 0; current && hops < devices.length; hops++) {
const rendered = renderedId(current);
if (rendered) {
return rendered;
}
const device = deviceByStat.get(current);
if (!device) {
return undefined;
}
current = device.included_in_stat;
}
return undefined;
};
devices.forEach((device, idx) => {
const id = getId(device);
if (!id) {
return;
}
const value = deviceValues.get(id)!;
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (value < minThreshold) {
@@ -155,14 +194,19 @@ export const buildSankeyDeviceNodes = (
smallConsumersByParent.forEach((allConsumers, parentKey) => {
// A small consumer whose included_in_stat chain reaches another small
// consumer is already counted inside that ancestor's value - drop it so
// totals don't double-count nested devices.
// totals don't double-count nested devices. A rendered ancestor ends the
// walk: the consumer links to it and never touches untracked, so it can't
// be double-counted through anything further up.
const consumers = allConsumers.filter((consumer) => {
let ancestor = consumer.includedInStat;
for (let hops = 0; ancestor && hops < devices.length; hops++) {
if (renderedId(ancestor)) {
return true;
}
if (smallConsumerStats.has(ancestor)) {
return false;
}
ancestor = deviceParents.get(ancestor);
ancestor = deviceByStat.get(ancestor)?.included_in_stat;
}
return true;
});
@@ -272,35 +272,6 @@ class HuiEnergySankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
// Set of device stats that will be rendered as their own node
const renderedStats = new Set<string>();
prefs.device_consumption.forEach((device) => {
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
renderedStats.add(device.stat_consumption);
}
});
// Walk up the included_in_stat chain to the first ancestor that is rendered
const deviceMap = new Map<string, string | undefined>();
prefs.device_consumption.forEach((device) => {
deviceMap.set(device.stat_consumption, device.included_in_stat);
});
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
if (renderedStats.has(currentParent)) {
return currentParent;
}
if (!deviceMap.has(currentParent)) {
return undefined;
}
currentParent = deviceMap.get(currentParent);
}
return undefined;
};
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
@@ -327,7 +298,6 @@ class HuiEnergySankeyCard
getValue: deviceValue,
getLabel: deviceLabel,
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -278,54 +278,6 @@ class HuiPowerSankeyCard
}
}
// Build a map of device relationships for hierarchy resolution
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentPower(device.stat_rate);
if (value >= minPowerThreshold) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for power hierarchy
// Walks up the chain to find an ancestor with stat_rate that will be rendered
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) {
return undefined;
}
// If this parent has a stat_rate and will be rendered, use it
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
// Otherwise, continue up the chain
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
const {
deviceNodes,
parentLinks,
@@ -344,7 +296,6 @@ class HuiPowerSankeyCard
getValue: (id) => this._getCurrentPower(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -241,48 +241,6 @@ class HuiWaterFlowSankeyCard
}
}
// Build a map of device relationships for hierarchy resolution
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentFlowRate(device.stat_rate);
if (value >= minFlowThreshold) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for hierarchy
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) return undefined;
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
const {
deviceNodes,
parentLinks,
@@ -301,7 +259,6 @@ class HuiWaterFlowSankeyCard
getValue: (id) => this._getCurrentFlowRate(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -215,35 +215,6 @@ class HuiWaterSankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
// Set of device stats that will be rendered as their own node
const renderedStats = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
renderedStats.add(device.stat_consumption);
}
});
// Walk up the included_in_stat chain to the first ancestor that is rendered
const deviceMap = new Map<string, string | undefined>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, device.included_in_stat);
});
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
if (renderedStats.has(currentParent)) {
return currentParent;
}
if (!deviceMap.has(currentParent)) {
return undefined;
}
currentParent = deviceMap.get(currentParent);
}
return undefined;
};
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
@@ -270,7 +241,6 @@ class HuiWaterSankeyCard
getValue: deviceValue,
getLabel: deviceLabel,
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
findEffectiveParent,
});
links.push(...deviceLinks);
+7 -7
View File
@@ -797,13 +797,6 @@
"target_details": "Target details",
"no_targets": "No targets",
"no_target_found": "No target found for {term}",
"selected": {
"entity": "Entities: {count}",
"device": "Devices: {count}",
"area": "Areas: {count}",
"label": "Labels: {count}",
"floor": "Floors: {count}"
},
"type": {
"area": "Area",
"areas": "Areas",
@@ -5184,6 +5177,13 @@
"area": "No areas or floors found for {term}",
"label": "No labels found for {term}"
},
"show_more_search": {
"trigger": "{count} more triggers...",
"condition": "{count} more conditions...",
"action": "{count} more actions...",
"entity": "{count} more entities...",
"device": "{count} more devices..."
},
"load_target_items_failed": "Failed to load target items for",
"other_areas": "Other areas",
"services": "Services",
+53 -1
View File
@@ -10,6 +10,8 @@
import { readFileSync } from "fs";
const REPORT_PATH = "test/e2e/reports/combined/results.json";
const COMMENT_MARKER = "<!-- playwright-e2e-report -->";
const COMMENT_HEADING = "## Playwright E2E tests failed";
// GitHub comment bodies cap at 65536 chars; leave headroom.
const MAX_BODY = 60000;
@@ -76,6 +78,19 @@ export default async function postReportComment({ github, context, core }) {
const { owner, repo } = context.repo;
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: context.issue.number,
per_page: 100,
});
const previousComments = comments.filter(
(comment) =>
comment.user?.login === "github-actions[bot]" &&
(comment.body?.includes(COMMENT_MARKER) ||
comment.body?.startsWith(COMMENT_HEADING))
);
let stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
let failures = [];
@@ -97,7 +112,8 @@ export default async function postReportComment({ github, context, core }) {
: "_No failing tests were captured in the report._";
let body = [
"## Playwright E2E tests failed",
COMMENT_MARKER,
COMMENT_HEADING,
"",
summaryLine,
"",
@@ -118,4 +134,40 @@ export default async function postReportComment({ github, context, core }) {
issue_number: context.issue.number,
body,
});
const batches = Array.from(
{ length: Math.ceil(previousComments.length / 100) },
(_, index) => previousComments.slice(index * 100, (index + 1) * 100)
);
const commentsToMinimize = (
await Promise.all(
batches.map(async (batch) => {
const { nodes } = await github.graphql(
`query($ids: [ID!]!) {
nodes(ids: $ids) {
... on IssueComment {
id
isMinimized
}
}
}`,
{ ids: batch.map((comment) => comment.node_id) }
);
return nodes.filter((node) => !node.isMinimized);
})
)
).flat();
await Promise.all(
commentsToMinimize.map((comment) =>
github.graphql(
`mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
clientMutationId
}
}`,
{ id: comment.id }
)
)
);
}
@@ -46,7 +46,6 @@ const cumulativeOpts = (
getValue: (id) => values[id] ?? 0,
getLabel: (id, name) => name || id,
getEntityId: (id) => id,
findEffectiveParent: () => undefined,
...rest,
};
};
@@ -168,6 +167,56 @@ describe("buildSankeyDeviceNodes", () => {
expect(result.untrackedConsumption).toBe(0);
});
it("resolves the parent to its node id, not its stat_consumption", () => {
// Instantaneous cards key the hierarchy by stat_consumption but render
// stat_rate, so the effective parent must come back as the stat_rate.
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p", stat_rate: "sensor.p" },
{
stat_consumption: "c",
stat_rate: "sensor.c",
included_in_stat: "p",
}
),
values: { "sensor.p": 100, "sensor.c": 40 },
minThreshold: 1,
initialUntracked: 100,
getId: (device) => device.stat_rate,
})
);
expect(result.parentLinks["sensor.c"]).toBe("sensor.p");
expect(result.links).toContainEqual({
source: "sensor.p",
target: "sensor.c",
});
expect(result.untrackedConsumption).toBe(0); // only the top-level parent
});
it("walks through an intermediate device that has no node id", () => {
// "middle" has no stat_rate, so the child must attach to the grandparent.
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p", stat_rate: "sensor.p" },
{ stat_consumption: "middle", included_in_stat: "p" },
{
stat_consumption: "c",
stat_rate: "sensor.c",
included_in_stat: "middle",
}
),
values: { "sensor.p": 100, "sensor.c": 40 },
minThreshold: 1,
initialUntracked: 100,
getId: (device) => device.stat_rate,
})
);
expect(result.parentLinks["sensor.c"]).toBe("sensor.p");
expect(result.untrackedConsumption).toBe(0);
});
it("adds a per-parent untracked residual above the floor", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
@@ -177,7 +226,6 @@ describe("buildSankeyDeviceNodes", () => {
),
values: { parent: 10, child: 4 },
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
expect(result.parentLinks.child).toBe("parent");
@@ -199,7 +247,6 @@ describe("buildSankeyDeviceNodes", () => {
values: { parent: 10, child: 9.5 },
untrackedFloor: 1,
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
expect(result.deviceNodes.some((n) => n.id.startsWith("untracked_"))).toBe(
@@ -217,7 +264,6 @@ describe("buildSankeyDeviceNodes", () => {
),
values: { parent: 10, s1: 0.003, s2: 0.004 },
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
const other = result.deviceNodes.find((n) => n.id === "other_parent");
@@ -307,6 +353,62 @@ describe("buildSankeyDeviceNodes", () => {
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
});
it("excludes a nested small device from a rendered parent's cluster", () => {
// big(rendered) > b(small) > c(small): c is inside b's value, so only b
// may be attributed under big.
const devs = devices(
{ stat_consumption: "big" },
{ stat_consumption: "b", included_in_stat: "big" },
{ stat_consumption: "c", included_in_stat: "b" }
);
const values = { big: 10, b: 0.004, c: 0.003 };
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devs,
values,
initialUntracked: 10,
})
);
// b is the lone survivor, so it is shown directly instead of "Other"
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"big",
"b",
"untracked_big",
]);
expect(result.parentLinks.b).toBe("big");
expect(
result.deviceNodes.find((n) => n.id === "untracked_big")?.value
).toBeCloseTo(10 - 0.004, 10);
expect(result.untrackedConsumption).toBe(0);
});
it("keeps a small device whose chain reaches a small ancestor past a rendered one", () => {
// g(small) > a(rendered) > c(small): c hangs off rendered a, so it never
// touches untracked and cannot be double-counted through g.
const devs = devices(
{ stat_consumption: "g" },
{ stat_consumption: "a", included_in_stat: "g" },
{ stat_consumption: "c", included_in_stat: "a" }
);
const values = { g: 0.0005, a: 0.5, c: 0.0004 };
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devs,
values,
initialUntracked: 1,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"a",
"g",
"c",
"untracked_a",
]);
expect(result.parentLinks.c).toBe("a");
// only the two top-level values (a and g) come off untracked
expect(result.untrackedConsumption).toBeCloseTo(1 - 0.5 - 0.0005, 10);
});
it("leaves a cyclic small-device cluster in untracked instead of looping", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
+15 -15
View File
@@ -4223,14 +4223,14 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:1.61.1":
version: 1.61.1
resolution: "@playwright/test@npm:1.61.1"
"@playwright/test@npm:1.62.0":
version: 1.62.0
resolution: "@playwright/test@npm:1.62.0"
dependencies:
playwright: "npm:1.61.1"
playwright: "npm:1.62.0"
bin:
playwright: cli.js
checksum: 10/08915357031e1bc273a19bb428fb7e51031ca44f25750832e389bcd69c399f6a243f5f832e7832d46153b57b1305f1f5f5dd63f3a3261f928f61e464c6b3c64e
checksum: 10/90b504f3933b97687580d7e6ba42ce9e73a9cdd41da5ae59441104b3dfa1508142ed17e5742acc70fdc2aef79078365859c6a463cebf46a8f7db9506a3ece1d6
languageName: node
linkType: hard
@@ -9952,7 +9952,7 @@ __metadata:
"@octokit/auth-oauth-device": "npm:8.0.3"
"@octokit/plugin-retry": "npm:8.1.0"
"@octokit/rest": "npm:22.0.1"
"@playwright/test": "npm:1.61.1"
"@playwright/test": "npm:1.62.0"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.5"
@@ -12763,27 +12763,27 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.61.1":
version: 1.61.1
resolution: "playwright-core@npm:1.61.1"
"playwright-core@npm:1.62.0":
version: 1.62.0
resolution: "playwright-core@npm:1.62.0"
bin:
playwright-core: cli.js
checksum: 10/b0e7b1d4de7ca6c1a57eb88f1709609a8b7637092f149e703d84d6c8e01835992ec5ca26b86f1a32fb5f5bc1aad042c3ae27311e131b3dda8a27824a543eb3c7
checksum: 10/1cf036e9ed51ea80d311ac25a871dddbc57d5e7e7a5ad23fc2741452aa8406c95e820901ec38aeecc6fca2ddc383ccc8acb0e2f29a4c12942dd0a1a63e7134a2
languageName: node
linkType: hard
"playwright@npm:1.61.1":
version: 1.61.1
resolution: "playwright@npm:1.61.1"
"playwright@npm:1.62.0":
version: 1.62.0
resolution: "playwright@npm:1.62.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.61.1"
playwright-core: "npm:1.62.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10/0cd1a8a7e106202e785450763973bf326d4aa112ed195bbd78b17af43dcfd12e29bc8204ae61c88f748dd0f2bdf69a7827bc3bbf8c1ec4c71b8e35586e05f13c
checksum: 10/52401b5f2b67abcb555c1b1c229e5204a3ac72a604343a99e60b00bd0cd46ad661a9471ced4e6c3ff39de957a0b1f413810d191ce81cd40e455700d73a6a80a5
languageName: node
linkType: hard