Compare commits

..
31 changed files with 892 additions and 1948 deletions
+1 -1
View File
@@ -107,7 +107,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"js-yaml": "5.3.0",
"js-yaml": "5.2.3",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
-1
View File
@@ -25,7 +25,6 @@ export type LocalizeKeys =
| `ui.dialogs.unsupported.reasons.${string}`
| `ui.panel.config.${string}.${"caption" | "description"}`
| `ui.panel.config.dashboard.${string}`
| `ui.panel.config.mqtt.${string}`
| `ui.panel.config.storage.segments.${string}`
| `ui.panel.config.zha.${string}`
| `ui.panel.config.zwave_js.${string}`
+7 -29
View File
@@ -33,7 +33,6 @@ import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeEntityName } from "../common/entity/compute_entity_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
@@ -753,19 +752,6 @@ export class HaCodeEditor extends ReactiveElement {
this._states![key]
);
const entityName = computeEntityName(
this._states![key],
this._registries!.entities,
this._registries!.devices
);
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
: undefined;
const completionItems: CompletionItem[] = [
{
label: this._i18n!.localize(
@@ -773,39 +759,31 @@ export class HaCodeEditor extends ReactiveElement {
),
value: formattedState,
subValue:
// If the state exactly matches the formatted state, don't show the raw state
this._states![key].state === formattedState
? undefined
: this._states![key].state,
},
];
if (entityName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.entity.entity-picker.entity"
),
value: entityName,
});
}
if (deviceName) {
if (context.device && context.device.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.device-picker.device"),
value: deviceName,
value: context.device.name,
});
}
if (areaName) {
if (context.area && context.area.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
value: areaName,
value: context.area.name,
});
}
if (floorName) {
if (context.floor && context.floor.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.floor-picker.floor"),
value: floorName,
value: context.floor.name,
});
}
+1 -1
View File
@@ -216,7 +216,7 @@ export class HaFilterDomains extends LitElement {
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-start: initial;
margin-inline-end: 8px;
}
ha-check-list-item {
-73
View File
@@ -1,73 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./chips/ha-assist-chip";
import "./ha-svg-icon";
/**
* Chip that opens a filter pane, with a badge showing how many filters are
* active.
*/
@customElement("ha-filter-pane-chip")
export class HaFilterPaneChip extends LitElement {
@property() public label = "";
/** SVG path of the leading icon. */
@property() public path?: string;
/** Number of active filters, shown as a badge when there is at least one. */
@property({ type: Number }) public count = 0;
@property({ type: Boolean }) public active = false;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-assist-chip
.label=${this.label}
.active=${this.active}
.disabled=${this.disabled}
>
${
this.path
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
: nothing
}
</ha-assist-chip>
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
`;
}
static styles = css`
:host {
position: relative;
display: inline-block;
--ha-assist-chip-container-shape: 10px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0 2px;
color: var(--text-primary-color);
pointer-events: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane-chip": HaFilterPaneChip;
}
}
-2
View File
@@ -43,8 +43,6 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
mqtt: () =>
import("../resources/mqtt-logo-svg").then((mod) => mod.mdiMqttLogo),
};
@customElement("ha-icon")
+10 -1
View File
@@ -1135,6 +1135,10 @@ export const resolveEntityIDs = (
expanded.areas.forEach((id) => targetAreas.add(id));
});
// Devices only reached through an area do not pull in entities that are
// explicitly assigned to another area, matching core.
const devicesNotViaArea = new Set(targetDevices);
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
hass,
@@ -1153,6 +1157,7 @@ export const resolveEntityIDs = (
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
devicesNotViaArea.add(device.id);
}
});
@@ -1163,7 +1168,11 @@ export const resolveEntityIDs = (
entities,
targetSelector
);
expanded.entities.forEach((id) => targetEntities.add(id));
expanded.entities.forEach((id) => {
if (devicesNotViaArea.has(deviceId) || !entities[id]?.area_id) {
targetEntities.add(id);
}
});
});
return Array.from(targetEntities);
-3
View File
@@ -184,7 +184,6 @@ export const checkForEntityUpdates = async (
}
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.checking_updates"),
});
@@ -195,7 +194,6 @@ export const checkForEntityUpdates = async (
if (computeDomain(event.data.entity_id) === "update") {
updated++;
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.updates_refreshed", {
count: updated,
}),
@@ -218,7 +216,6 @@ export const checkForEntityUpdates = async (
if (updated === 0) {
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
-22
View File
@@ -1,7 +1,6 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { callWS } from "../util/websocket";
import type { DeviceRegistryEntry } from "./device/device_registry";
export enum InclusionState {
/** The controller isn't doing anything regarding inclusion. */
@@ -466,27 +465,6 @@ export interface RequestedGrant {
clientSideAuth: boolean;
}
/**
* Get the Z-Wave node ID of a device from its registry identifiers, which have
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
* Returns undefined for devices without a node, e.g. provisioning entries.
*/
export const getNodeIdFromDevice = (
device: DeviceRegistryEntry
): number | undefined => {
for (const [domain, identifier] of device.identifiers) {
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
continue;
}
const nodeId = parseInt(identifier.split("-")[1]);
if (!isNaN(nodeId)) {
return nodeId;
}
}
return undefined;
};
export const invokeZWaveCCApi = <T = unknown>(
hass: HomeAssistant,
device_id: string,
+44 -13
View File
@@ -34,7 +34,6 @@ import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
import "../components/ha-dropdown-item";
import "../components/ha-filter-pane-chip";
import "../components/ha-icon-button";
import "../components/ha-svg-icon";
import "../components/input/ha-input-search";
@@ -238,13 +237,20 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
const localize = this.localizeFunc || this.hass.localize;
const showPane = this._showPaneController.value ?? !this.narrow;
const filterButton = this.hasFilters
? html`<ha-filter-pane-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.path=${mdiFilterVariant}
.count=${this.filters}
.active=${!!this.filters}
@click=${this._toggleFilters}
></ha-filter-pane-chip>`
? html`<div class="relative">
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.active=${this.filters}
@click=${this._toggleFilters}
>
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<div class="badge">${this.filters}</div>`
: nothing
}
</div>`
: nothing;
const selectModeBtn =
@@ -465,14 +471,18 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? nothing
: html`<div class="pane" slot="pane">
<div class="table-header">
<ha-filter-pane-chip
<ha-assist-chip
.label=${localize(
"ui.components.subpage-data-table.filters"
)}
.path=${mdiFilterVariant}
active
@click=${this._toggleFilters}
></ha-filter-pane-chip>
>
<ha-svg-icon
slot="icon"
.path=${mdiFilterVariant}
></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<ha-icon-button
@@ -875,6 +885,24 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
padding: 16px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.narrow-header-row {
display: flex;
align-items: center;
@@ -923,8 +951,11 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
gap: var(--ha-space-2);
}
ha-assist-chip,
ha-filter-pane-chip {
.relative {
position: relative;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
--ha-assist-chip-container-color: var(--card-background-color);
}
@@ -49,7 +49,6 @@ const SUPPORTED_UI_TYPES = [
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
identify: () => false,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<div class="description-text">${this._currentAddon.description}</div>
${this._currentAddon.description}.<br />
${this.i18n.localize(
"ui.panel.config.apps.dashboard.visit_app_page",
{
@@ -1658,15 +1658,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--primary-color);
}
.description:dir(rtl) > .description-text {
text-align: right;
direction: ltr;
}
.long-description {
direction: ltr;
}
img.logo {
max-width: 100%;
max-height: 40px;
@@ -33,31 +33,23 @@ interface ProgressSegment {
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
// Quick metadata writes emitted while the backup is initialized, before the
// Home Assistant stage (docker_config only by older Supervisors)
const SETUP_STAGES: CreateBackupStage[] = [
const ADDON_STAGES: CreateBackupStage[] = [
"addons",
"apps",
"addon_repositories",
"app_repositories",
"docker_config",
];
const ADDON_STAGES: CreateBackupStage[] = ["addons", "apps"];
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Emitted after the backup file is finished, when the backend waits for
// cold-backup add-ons to come back up
const AWAIT_RESTART_STAGES: CreateBackupStage[] = [
"await_addon_restarts",
"await_app_restarts",
];
// Ordered groups matching actual backend execution order. The await restart
// stages share the last creation group to keep the progress monotonic.
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Ordered groups matching actual backend execution order
const STAGE_ORDER: CreateBackupStage[][] = [
[...SETUP_STAGES, ...HA_STAGES],
ADDON_STAGES,
[...MEDIA_STAGES, ...AWAIT_RESTART_STAGES],
MEDIA_STAGES,
HA_STAGES,
["upload_to_agents"],
["cleaning_up"],
];
@@ -173,21 +165,21 @@ export class HaBackupOverviewProgress extends LitElement {
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.apps"
),
state: this._getSegmentState(0, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.apps"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(1, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
@@ -209,18 +201,18 @@ export class HaBackupOverviewProgress extends LitElement {
];
}
// Non-HAOS: No app segment, just HA, Media, Upload and Cleaning up
// Non-HAOS: No app segment, just Media, HA, Upload and Cleaning up
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(0, currentGroupIndex),
state: this._getSegmentState(1, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
-9
View File
@@ -39,7 +39,6 @@ import {
import memoizeOne from "memoize-one";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
import { mdiMqttLogo } from "../../resources/mqtt-logo-svg";
const getHasDomainCheck = (domain: string) => {
const prefix = `${domain}.`;
@@ -143,14 +142,6 @@ export const configSections: Record<string, PageNavigation[]> = {
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/mqtt",
iconPath: mdiMqttLogo,
iconColor: "#660066",
component: "mqtt",
translationKey: "mqtt",
adminOnly: true,
},
{
path: "/config/thread",
iconPath:
@@ -1,27 +1,14 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
mdiAlertCircleOutline,
mdiCheck,
mdiDevices,
mdiShape,
mdiTune,
} from "@mdi/js";
import { storage } from "../../../../../common/decorators/storage";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-switch";
import "../../../../../components/input/ha-input";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
import type { Action } from "../../../../../data/script";
import { callExecuteScript } from "../../../../../data/service";
@@ -31,10 +18,6 @@ import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import "./mqtt-subscribe-card";
import { brandsUrl } from "../../../../../util/brands-url";
import { showConfigFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-config-flow";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import { mdiMqttLogo } from "../../../../../resources/mqtt-logo-svg";
const qosLevel = ["0", "1", "2"];
@@ -76,240 +59,78 @@ export class MQTTConfigPanel extends LitElement {
})
private _retain = false;
@state() private _configEntry?: ConfigEntry;
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchConfigEntry();
}
}
private _MQTTDeviceIds = memoizeOne(
(
devices: HomeAssistant["devices"],
configEntryId?: string
): Set<string> => {
if (!configEntryId) {
return new Set();
}
return new Set(
Object.values(devices)
.filter((device) => device.config_entries.includes(configEntryId))
.map((device) => device.id)
);
}
);
private _entityCount = memoizeOne(
(entities: HomeAssistant["entities"], deviceIds: Set<string>): number =>
Object.values(entities).filter(
(entity) => entity.device_id && deviceIds.has(entity.device_id)
).length
);
protected render(): TemplateResult | typeof nothing {
if (!this._configEntry) {
return nothing;
}
const isOnline = this._configEntry.state === "loaded";
const deviceIds = this._MQTTDeviceIds(
this.hass.devices,
this._configEntry.entry_id
);
const entityCount = this._entityCount(this.hass.entities, deviceIds);
protected render(): TemplateResult {
return html`
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
header="MQTT"
back-path="/config/integrations/integration/mqtt"
has-fab
>
<div class="content">
<div class="container">
${this._renderNetworkStatus(isOnline, deviceIds.size)}
${this._renderMyNetworkCard(deviceIds.size, entityCount)}
${this._renderNavigationCard()} ${this._renderPublishCard()}
<mqtt-subscribe-card .hass=${this.hass}></mqtt-subscribe-card>
</div>
<ha-card
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._openOptionFlow}
>${this.hass.localize(
"ui.panel.config.mqtt.option_flow"
)}</ha-button
>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.mqtt.description_publish"
)}
>
<div class="card-content">
<div class="panel-dev-mqtt-fields">
<ha-input
.label=${this.hass.localize("ui.panel.config.mqtt.topic")}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-formfield
label=${this.hass!.localize("ui.panel.config.mqtt.retain")}
>
<ha-switch
@change=${this._handleRetain}
.checked=${this._retain}
></ha-switch>
</ha-formfield>
</div>
<p>${this.hass.localize("ui.panel.config.mqtt.payload")}</p>
<ha-code-editor
mode="jinja2"
autocomplete-entities
autocomplete-icons
.value=${this._payload}
@value-changed=${this._handlePayload}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._publish}
>${this.hass.localize(
"ui.panel.config.mqtt.publish"
)}</ha-button
>
</div>
</ha-card>
<mqtt-subscribe-card .hass=${this.hass}></mqtt-subscribe-card>
</div>
</hass-subpage>
`;
}
private _renderNetworkStatus(isOnline: boolean, deviceCount: number) {
return html`
<ha-card class="content network-status">
<div class="card-content">
<div class="heading">
<div class="icon ${isOnline ? "success" : "error"}">
<ha-svg-icon
.path=${isOnline ? mdiCheck : mdiAlertCircleOutline}
></ha-svg-icon>
</div>
<div class="details">
${this.hass.localize(
`ui.panel.config.mqtt.status_${isOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize("ui.panel.config.mqtt.devices", {
count: deviceCount,
})}
</small>
</div>
<img
class="logo"
alt="MQTT"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: "mqtt",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
</div>
</div>
</ha-card>
`;
}
private _renderMyNetworkCard(deviceCount: number, entityCount: number) {
return html`
<ha-card class="nav-card">
<div class="card-header">
${this.hass.localize("ui.panel.config.mqtt.my_network_title")}
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.device_count", {
count: deviceCount,
})}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item
type="link"
href=${`/config/entities/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.entity_count", {
count: entityCount,
})}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderNavigationCard() {
return html`
<ha-card class="nav-card">
<div class="card-content">
<ha-md-list>
<ha-md-list-item type="link" @click=${this._openOptionFlow}>
<ha-svg-icon slot="start" .path=${mdiTune}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.option_flow")}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.mqtt.option_flow_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" @click=${this._openConfigFlow}>
<ha-svg-icon slot="start" .path=${mdiMqttLogo}></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.mqtt.config_flow")}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.mqtt.config_flow_description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
private _renderPublishCard() {
return html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.mqtt.description_publish"
)}
>
<div class="card-content">
<div class="panel-dev-mqtt-fields">
<ha-input
.label=${this.hass.localize("ui.panel.config.mqtt.topic")}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-formfield
label=${this.hass!.localize("ui.panel.config.mqtt.retain")}
>
<ha-switch
@change=${this._handleRetain}
.checked=${this._retain}
></ha-switch>
</ha-formfield>
</div>
<p>${this.hass.localize("ui.panel.config.mqtt.payload")}</p>
<ha-code-editor
mode="jinja2"
autocomplete-entities
autocomplete-icons
.value=${this._payload}
@value-changed=${this._handlePayload}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._publish}
>${this.hass.localize("ui.panel.config.mqtt.publish")}</ha-button
>
</div>
</ha-card>
`;
}
private async _fetchConfigEntry(): Promise<void> {
const configEntries = await getConfigEntries(this.hass, {
domain: "mqtt",
});
this._configEntry = configEntries.find(
(entry) => entry.disabled_by === null && entry.source !== "ignore"
);
}
private _handleTopic(ev: InputEvent) {
this._topic = (ev.target as HTMLInputElement).value;
}
@@ -354,23 +175,19 @@ export class MQTTConfigPanel extends LitElement {
}
private async _openOptionFlow() {
showOptionsFlowDialog(this, this._configEntry!);
}
private _openConfigFlow = async () => {
if (!this._configEntry) {
const searchParams = new URLSearchParams(window.location.search);
if (!searchParams.has("config_entry")) {
return;
}
showConfigFlowDialog(this, {
startFlowHandler: this._configEntry.domain,
manifest: await fetchIntegrationManifest(
this.hass,
this._configEntry.domain
),
entryId: this._configEntry.entry_id,
navigateToResult: true,
const configEntryId = searchParams.get("config_entry") as string;
const configEntries = await getConfigEntries(this.hass, {
domain: "mqtt",
});
};
const configEntry = configEntries.find(
(entry) => entry.entry_id === configEntryId
);
showOptionsFlowDialog(this, configEntry!);
}
static get styles(): CSSResultGroup {
return [
@@ -382,37 +199,17 @@ export class MQTTConfigPanel extends LitElement {
-moz-user-select: initial;
}
.nav-card {
overflow: hidden;
}
.nav-card .card-content {
padding: 0;
}
.nav-card .card-header {
padding-bottom: var(--ha-space-2);
}
.content {
margin-top: var(--ha-space-6);
padding: 24px 0 32px;
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
.panel-dev-mqtt-fields {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
ha-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-item-overflow: visible;
}
ha-select {
width: 96px;
margin: 0 8px;
@@ -439,78 +236,6 @@ export class MQTTConfigPanel extends LitElement {
display: block;
margin: 16px auto;
}
.network-status div.heading {
display: flex;
align-items: center;
column-gap: var(--ha-space-4);
}
.network-status div.heading .logo {
height: 40px;
width: 40px;
margin-inline-start: auto;
object-fit: contain;
}
.network-status div.heading .icon {
position: relative;
border-radius: var(--ha-border-radius-2xl);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
--icon-color: var(--primary-color);
}
.network-status div.heading .icon.success {
--icon-color: var(--success-color);
}
.network-status div.heading .icon.error {
--icon-color: var(--error-color);
}
.network-status div.heading .icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color);
opacity: 0.2;
}
.network-status div.heading .icon ha-svg-icon {
color: var(--icon-color);
width: 24px;
height: 24px;
}
.network-status div.heading .details {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
color: var(--primary-text-color);
}
.network-status small {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.25px;
color: var(--secondary-text-color);
}
.container {
padding: var(--ha-space-2) var(--ha-space-4)
calc(var(--ha-space-16) + var(--safe-area-inset-bottom, 0px));
}
a[slot="fab"] {
text-decoration: none;
}
`,
];
}
@@ -1,20 +1,15 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiContentCopy } from "@mdi/js";
import { formatTime } from "../../../../../common/datetime/format_time";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-markdown";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import "../../../../../components/ha-select";
import "../../../../../components/input/ha-input";
import type { MQTTMessage } from "../../../../../data/mqtt";
import { subscribeMQTTTopic } from "../../../../../data/mqtt";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import { storage } from "../../../../../common/decorators/storage";
import "../../../../../components/ha-formfield";
@@ -73,56 +68,53 @@ class MqttSubscribeCard extends LitElement {
return html`
<ha-card
header=${this.hass.localize("ui.panel.config.mqtt.description_listen")}
class="content_subscribe_panel"
>
<div class="card-content">
<form>
<p>
<ha-formfield
label=${this.hass!.localize(
"ui.panel.config.mqtt.json_formatting"
)}
>
<ha-switch
@change=${this._handleJSONFormat}
.checked=${this._json_format}
></ha-switch>
</ha-formfield>
</p>
<div class="panel-dev-mqtt-subscribe-fields">
<ha-input
.label=${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.listening_to")
: this.hass.localize("ui.panel.config.mqtt.subscribe_to")
}
.disabled=${this._subscribed !== undefined}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.disabled=${this._subscribed !== undefined}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-button
appearance="plain"
size="s"
.disabled=${this._topic === ""}
@click=${this._handleSubmit}
>
${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.stop_listening")
: this.hass.localize("ui.panel.config.mqtt.start_listening")
}
</ha-button>
</div>
</form>
</div>
<form>
<p>
<ha-formfield
label=${this.hass!.localize(
"ui.panel.config.mqtt.json_formatting"
)}
>
<ha-switch
@change=${this._handleJSONFormat}
.checked=${this._json_format}
></ha-switch>
</ha-formfield>
</p>
<div class="panel-dev-mqtt-subscribe-fields">
<ha-input
.label=${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.listening_to")
: this.hass.localize("ui.panel.config.mqtt.subscribe_to")
}
.disabled=${this._subscribed !== undefined}
.value=${this._topic}
@change=${this._handleTopic}
></ha-input>
<ha-select
.label=${this.hass.localize("ui.panel.config.mqtt.qos")}
.disabled=${this._subscribed !== undefined}
.value=${this._qos}
@selected=${this._handleQos}
.options=${qosLevel}
>
</ha-select>
<ha-button
appearance="plain"
size="s"
.disabled=${this._topic === ""}
@click=${this._handleSubmit}
>
${
this._subscribed
? this.hass.localize("ui.panel.config.mqtt.stop_listening")
: this.hass.localize("ui.panel.config.mqtt.start_listening")
}
</ha-button>
</div>
</form>
<div class="events">
${this._messages.map(
(msg) => html`
@@ -136,17 +128,7 @@ class MqttSubscribeCard extends LitElement {
this.hass!.config
),
})}
<div class="code-block">
<ha-icon-button
class="copy-button"
.path=${mdiContentCopy}
@click=${this._handleCopyClick}
data-payload=${msg.payload}
></ha-icon-button>
<ha-markdown
.content=${`\`\`\`${this._json_format ? "json" : ""}\n${msg.payload}\n\`\`\``}
></ha-markdown>
</div>
<pre>${msg.payload}</pre>
<div class="bottom">
QoS: ${msg.message.qos} - Retain:
${Boolean(msg.message.retain)}
@@ -174,16 +156,6 @@ class MqttSubscribeCard extends LitElement {
this._json_format = (ev.target! as any).checked;
}
private async _handleCopyClick(ev: Event): Promise<void> {
const payload = (ev.target as HTMLElement).getAttribute("data-payload");
if (payload) {
await copyToClipboard(payload);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
}
private async _handleSubmit(): Promise<void> {
if (this._subscribed) {
this._subscribed();
@@ -227,12 +199,6 @@ class MqttSubscribeCard extends LitElement {
padding: var(--ha-space-4);
padding-bottom: var(--ha-space-8);
}
.content_subscribe_panel {
margin-top: var(--ha-space-6);
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
.events {
margin: -16px 0;
padding: 0 16px;
@@ -264,20 +230,6 @@ class MqttSubscribeCard extends LitElement {
ha-input {
flex: 1;
}
.code-block {
position: relative;
margin-bottom: 16px;
}
.code-block ha-markdown {
padding-right: 40px;
}
.copy-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
color: var(--secondary-text-color);
}
@media screen and (max-width: 600px) {
ha-select {
display: block;
@@ -2,7 +2,6 @@ import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -20,13 +19,11 @@ import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
RssiError,
ZWaveJSNodeStatisticsUpdatedMessage,
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
subscribeZwaveNodeStatistics,
} from "../../../../../data/zwave_js";
@@ -57,36 +54,19 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _searchFilter = "";
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
const nodeIdsByDeviceId: Record<string, number> = {};
const devices = Object.values(this.hass.devices).filter((device) =>
device.config_entries.some((entry) => entry === this.configEntryId)
);
Object.values(this.hass.devices).forEach((device) => {
if (!device.config_entries.includes(this.configEntryId)) {
return;
}
const nodeId = getNodeIdFromDevice(device);
if (nodeId === undefined) {
return;
}
devices[nodeId] = device;
nodeIdsByDeviceId[device.id] = nodeId;
subscriptions.push(
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
this._nodeStatistics[nodeId] = message;
this._handleUpdatedNodeStatistics();
})
);
});
this._nodeIdsByDeviceId = nodeIdsByDeviceId;
this._devices = devices;
return subscriptions;
return devices.map((device) =>
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
const nodeId = message.nodeId ?? message.node_id;
this._devices[nodeId!] = device;
this._nodeStatistics[nodeId!] = message;
this._handleUpdatedNodeStatistics();
})
);
}
public connectedCallback() {
@@ -184,10 +164,8 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
const route = stats?.lwr || stats?.nlwr;
const route =
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
return html`${sourceName}
${targetName}${
route?.protocol_data_rate
@@ -355,69 +333,55 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
if (controllerNode === undefined) {
return { nodes, links, categories };
}
const controllerId = String(controllerNode);
Object.entries(nodeStatistics).forEach(([nodeId, stats]) => {
const route = stats.lwr || stats.nlwr;
if (!route) {
return;
if (route) {
const hops = [
...route.repeaters.map((id, i) => [
Object.keys(this._devices).find(
(_nodeId) => this._devices[_nodeId]?.id === id
)?.[0],
route.repeater_rssi[i],
]),
[controllerNode!, route.rssi],
];
let sourceNode: string = nodeId;
hops.forEach(([repeater, rssi]) => {
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) =>
link.source === sourceNode && link.target === String(repeater)
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target: String(repeater),
value: RSSI,
lineStyle: {
width,
color:
repeater === controllerNode
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = String(repeater);
});
}
// Routes go from the controller to the node via the repeaters, in order.
// Each station measures the hop leaving it: the controller reports
// `rssi`, repeater i reports `repeater_rssi[i]`.
const hops: [string, RssiError | number | null][] = [];
let hopRssi = route.rssi;
route.repeaters.forEach((deviceId, i) => {
const repeaterNodeId = this._nodeIdsByDeviceId[deviceId];
// skip repeaters we can't resolve, so the chain stays connected
if (repeaterNodeId !== undefined) {
hops.push([String(repeaterNodeId), hopRssi]);
}
hopRssi = route.repeater_rssi[i];
});
hops.push([nodeId, hopRssi]);
let sourceNode = controllerId;
hops.forEach(([target, rssi]) => {
if (target === sourceNode) {
return;
}
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) => link.source === sourceNode && link.target === target
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target,
value: RSSI,
lineStyle: {
width,
color:
sourceNode === controllerId
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = target;
});
});
return { nodes, links, categories };
@@ -203,28 +203,9 @@ export const showRepairsFlowDialog = (
return "";
},
renderCreateEntryDescription(hass, step) {
const description = hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.create_entry.${step.description || "default"}`,
step.description_placeholders
);
renderCreateEntryDescription(hass, _step) {
return html`
${
description
? html`
<ha-markdown
allow-svg
breaks
.content=${description}
></ha-markdown>
`
: html`<p>
${hass.localize("ui.dialogs.repair_flow.success.description")}
</p>`
}
<p>${hass.localize("ui.dialogs.repair_flow.success.description")}</p>
`;
},
@@ -11,11 +11,6 @@ import type { HomeAssistant } from "../../../../../types";
// Devices below this fraction of the home total are grouped into an "Other" node
export const MIN_SANKEY_THRESHOLD_FACTOR = 0.001; // 0.1% of home total
// Readable capacity of the default 400px card: ~18px per node including the
// 6px nodeGap. Past this, bars collapse toward the 1px minimum and the labels
// shrink with them.
export const DEFAULT_MAX_SANKEY_DEVICES = 20;
// While the graph is assembled, device nodes carry an ad-hoc `parent` field
// that is not part of the chart's Node interface. The chart ignores it.
export type SankeyDeviceNode = Node & { parent?: string };
@@ -41,103 +36,6 @@ export const fireSankeyNodeMoreInfo = (el: HTMLElement, node: Node): void => {
}
};
export interface FindDevicesOverCapOptions {
devices: DeviceConsumptionEnergyPreference[];
/** Max named children per parent. Falsy or non-positive disables the cap. */
maxDevices: number | undefined;
rootNodeId: string;
renderedIds: ReadonlySet<string>;
deviceValues: ReadonlyMap<string, number>;
getId: (device: DeviceConsumptionEnergyPreference) => string | undefined;
/** First ancestor rendered as its own node, else undefined. */
getEffectiveParent: (
device: DeviceConsumptionEnergyPreference
) => string | undefined;
}
/**
* Node ids to drop from the rendered set because their parent has more rendered
* children than `maxDevices`. Callers group the result into the parent's "Other"
* node, so the smallest devices merge instead of disappearing.
*/
export const findDevicesOverCap = ({
devices,
maxDevices,
rootNodeId,
renderedIds,
deviceValues,
getId,
getEffectiveParent,
}: FindDevicesOverCapOptions): Set<string> => {
const grouped = new Set<string>();
if (!maxDevices || !Number.isFinite(maxDevices) || maxDevices <= 0) {
return grouped;
}
const childrenByParent = new Map<
string,
{ id: string; value: number; idx: number }[]
>();
const seenIds = new Set<string>();
devices.forEach((device, idx) => {
const id = getId(device);
if (!id || !renderedIds.has(id) || seenIds.has(id)) {
return;
}
seenIds.add(id);
const parentKey = getEffectiveParent(device) ?? rootNodeId;
if (!childrenByParent.has(parentKey)) {
childrenByParent.set(parentKey, []);
}
childrenByParent.get(parentKey)!.push({
id,
value: deviceValues.get(id)!,
idx,
});
});
// Taking the whole subtree stops a grouped device's children from re-parenting
// upward while its value, which already includes them, rolls into "Other".
const groupSubtree = (id: string): void => {
if (grouped.has(id)) {
return;
}
grouped.add(id);
childrenByParent.get(id)?.forEach((child) => groupSubtree(child.id));
};
const queue = [rootNodeId];
const visited = new Set(queue);
while (queue.length) {
const children = childrenByParent.get(queue.shift()!);
if (!children) {
continue;
}
let kept = children;
if (children.length > maxDevices) {
// Raw value includes children, so a parent never sorts below its own
// child; declaration order breaks ties so the layout is stable.
const ranked = [...children].sort(
(a, b) => a.value - b.value || a.idx - b.idx
);
// At least two, because a lone grouped device is rendered by name instead
// and would void the cap.
ranked
.slice(0, Math.max(children.length - maxDevices, 2))
.forEach((child) => groupSubtree(child.id));
kept = children.filter((child) => !grouped.has(child.id));
}
kept.forEach((child) => {
if (!visited.has(child.id)) {
visited.add(child.id);
queue.push(child.id);
}
});
}
return grouped;
};
export interface BuildSankeyDeviceNodesOptions {
devices: DeviceConsumptionEnergyPreference[];
computedStyle: CSSStyleDeclaration;
@@ -146,12 +44,6 @@ export interface BuildSankeyDeviceNodesOptions {
rootNodeId: string;
/** Flows below this value are grouped into an "Other" node. */
minThreshold: number;
/**
* Max named child nodes per parent. Once a parent has more rendered children
* than this, the smallest are grouped into that parent's "Other" node.
* Undefined means no cap.
*/
maxDevices?: number;
/** Per-parent untracked residual only shown when above this value. */
untrackedFloor: number;
/** Round the "Other" node value up (instantaneous cards only). */
@@ -184,7 +76,6 @@ export const buildSankeyDeviceNodes = (
localize,
rootNodeId,
minThreshold,
maxDevices,
untrackedFloor,
ceilOtherValue,
initialUntracked,
@@ -255,19 +146,6 @@ export const buildSankeyDeviceNodes = (
return undefined;
};
// The value threshold only catches devices that are a negligible share of the
// home total, so a panel of dozens of similar-sized circuits never groups.
findDevicesOverCap({
devices,
maxDevices,
rootNodeId,
renderedIds,
deviceValues,
getId,
getEffectiveParent: (device) =>
findEffectiveParent(device.included_in_stat),
}).forEach((id) => renderedIds.delete(id));
devices.forEach((device, idx) => {
const id = getId(device);
if (!id) {
@@ -276,9 +154,8 @@ export const buildSankeyDeviceNodes = (
const value = deviceValues.get(id)!;
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (!renderedIds.has(id)) {
// Below the value threshold or demoted by the count cap. Collect it
// instead of folding it into untracked.
if (value < minThreshold) {
// Collect small consumers instead of folding them into untracked
const parentKey = effectiveParent ?? rootNodeId;
if (!smallConsumersByParent.has(parentKey)) {
smallConsumersByParent.set(parentKey, []);
@@ -30,7 +30,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "./common/sankey";
@@ -299,7 +298,6 @@ class HuiEnergySankeyCard
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minEnergyThreshold,
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
untrackedFloor: 0,
ceilOtherValue: false,
initialUntracked: homeNode.value,
@@ -23,7 +23,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "./common/sankey";
@@ -298,7 +297,6 @@ class HuiPowerSankeyCard
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minPowerThreshold,
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
untrackedFloor: 1,
ceilOtherValue: true,
initialUntracked: homeNode.value,
-1
View File
@@ -174,7 +174,6 @@ export interface EnergyCardSankeyConfig extends EnergyCardConfig {
layout?: "auto" | "vertical" | "horizontal";
group_by_floor?: boolean;
group_by_area?: boolean;
max_devices?: number;
}
export interface EnergyDateSelectorCardConfig extends EnergyCardBaseConfig {
@@ -22,7 +22,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "../energy/common/sankey";
@@ -261,7 +260,6 @@ class HuiWaterFlowSankeyCard
localize: this.hass.localize,
rootNodeId,
minThreshold: minFlowThreshold,
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
untrackedFloor: 1,
ceilOtherValue: true,
initialUntracked: effectiveTotalInflow,
@@ -27,7 +27,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "../energy/common/sankey";
@@ -242,7 +241,6 @@ class HuiWaterSankeyCard
localize: this.hass.localize,
rootNodeId: "home",
minThreshold: minWaterThreshold,
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
untrackedFloor: 0,
ceilOtherValue: false,
initialUntracked: homeNode.value,
@@ -5,7 +5,6 @@ import {
assign,
boolean,
literal,
number,
object,
optional,
string,
@@ -37,7 +36,6 @@ const cardConfigStruct = assign(
),
group_by_floor: optional(boolean()),
group_by_area: optional(boolean()),
max_devices: optional(number()),
})
);
@@ -94,11 +92,6 @@ export class HuiEnergySankeyCardEditor
},
],
},
{
name: "max_devices",
required: false,
selector: { number: { min: 1, mode: "box" } },
},
{
type: "string",
name: "collection_key",
@@ -141,10 +134,6 @@ export class HuiEnergySankeyCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.collection_key_description`
);
case "max_devices":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.energy-sankey.max_devices_description`
);
default:
return undefined;
}
@@ -155,7 +144,6 @@ export class HuiEnergySankeyCardEditor
case "layout":
case "group_by_floor":
case "group_by_area":
case "max_devices":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.energy-sankey.${schema.name}`
);
-2
View File
@@ -1,2 +0,0 @@
export const mdiMqttLogo =
"M1.68,0.5325c-0.63,0-1.1475,0.51-1.1475,1.1475v1.125c11.445,0.0675,20.745,9.3,20.82,20.67h0.975c0.63,0,1.1475-0.51,1.1475-1.1475V14.5125C20.8575,8.04,15.5475,2.9175,8.925,0.5325H1.68z M0.5325,6.3075v3.735c7.425,0.0675,13.455,6.0525,13.53,13.4325h3.8775C17.865,13.995,10.0875,6.315,0.5325,6.3075z M0.5325,13.545v8.7825c0,0.63,0.51,1.1475,1.1475,1.1475H10.65C10.575,17.985,6.0675,13.5525,0.5325,13.545z M16.53,0.5325c1.35,0.945,2.715,2.01,3.915,3.2025c1.0875,1.08,2.145,2.3775,3.03,3.585V1.68c0-0.63-0.51-1.1475-1.1475-1.1475H16.53z";
+2 -17
View File
@@ -1608,7 +1608,6 @@
"labs": "[%key:ui::panel::config::labs::caption%]",
"tools": "[%key:ui::panel::config::dashboard::tools::main%]",
"matter": "[%key:ui::panel::config::dashboard::matter::main%]",
"mqtt": "[%key:ui::panel::config::dashboard::mqtt::main%]",
"zha": "[%key:ui::panel::config::dashboard::zha::main%]",
"zwave_js": "[%key:ui::panel::config::dashboard::zwave_js::main%]",
"thread": "[%key:ui::panel::config::dashboard::thread::main%]",
@@ -2737,10 +2736,6 @@
"main": "Matter",
"secondary": "Cross-vendor smart home standard"
},
"mqtt": {
"main": "MQTT",
"secondary": "Lightweight network protocol designed for IoT devices"
},
"thread": {
"main": "Thread",
"secondary": "Mesh network often used for Matter devices"
@@ -7405,16 +7400,8 @@
},
"mqtt": {
"title": "MQTT",
"option_flow": "MQTT options",
"option_flow_description": "Set discovery options",
"config_flow": "Connection",
"config_flow_description": "Broker connection settings",
"status_online": "Online",
"status_offline": "Offline",
"my_network_title": "My network",
"devices": "{count, plural,\n one {# device}\n other {# devices}\n}",
"device_count": "{count, plural,\n one {# device}\n other {# devices}\n}",
"entity_count": "{count, plural,\n one {# entity}\n other {# entities}\n}",
"settings_title": "MQTT settings",
"option_flow": "Configure MQTT options",
"description_publish": "Publish a packet",
"topic": "Topic",
"payload": "Payload (template allowed)",
@@ -10007,8 +9994,6 @@
"description": "This card shows the distribution of energy in your home on a Sankey graph.",
"group_by_floor": "Group by floor",
"group_by_area": "Group by area",
"max_devices": "Maximum devices per parent",
"max_devices_description": "Devices beyond this limit are grouped into an \"Other\" node, smallest first. The limit applies per upstream device, not per floor or area. Defaults to 20.",
"layout": "Graph direction",
"layout_directions": {
"auto": "Automatic",
+63 -1
View File
@@ -1,7 +1,10 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import { filterSelectorEntities } from "../../src/data/selector";
import {
filterSelectorEntities,
resolveEntityIDs,
} from "../../src/data/selector";
import type { HomeAssistant } from "../../src/types";
const entity = {
@@ -132,3 +135,62 @@ describe("filterSelectorEntities device filter", () => {
).toBe(false);
});
});
describe("resolveEntityIDs", () => {
const areaHass = {
states: {
"light.kitchen": { entity_id: "light.kitchen", state: "on" },
"sensor.kitchen_hub_temp": {
entity_id: "sensor.kitchen_hub_temp",
state: "20",
},
"sensor.hallway_probe": { entity_id: "sensor.hallway_probe", state: "5" },
},
entities: {
"light.kitchen": { entity_id: "light.kitchen", device_id: "hub" },
"sensor.kitchen_hub_temp": {
entity_id: "sensor.kitchen_hub_temp",
device_id: "hub",
},
"sensor.hallway_probe": {
entity_id: "sensor.hallway_probe",
device_id: "hub",
area_id: "hallway",
},
},
devices: { hub: { id: "hub", area_id: "kitchen" } },
areas: { kitchen: { area_id: "kitchen" }, hallway: { area_id: "hallway" } },
} as unknown as HomeAssistant;
const resolve = (target) =>
resolveEntityIDs(
areaHass,
target,
areaHass.entities,
areaHass.devices,
areaHass.areas
).sort();
it("skips entities of an area device that are assigned to another area", () => {
expect(resolve({ area_id: "kitchen" })).toEqual([
"light.kitchen",
"sensor.kitchen_hub_temp",
]);
});
it("keeps every entity of a directly targeted device", () => {
expect(resolve({ device_id: "hub" })).toEqual([
"light.kitchen",
"sensor.hallway_probe",
"sensor.kitchen_hub_temp",
]);
});
it("keeps the entity when its own area is targeted as well", () => {
expect(resolve({ area_id: ["kitchen", "hallway"] })).toEqual([
"light.kitchen",
"sensor.hallway_probe",
"sensor.kitchen_hub_temp",
]);
});
});
-63
View File
@@ -1,63 +0,0 @@
import { describe, it, expect } from "vitest";
import { getNodeIdFromDevice } from "../../src/data/zwave_js";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
const mockDevice = (identifiers: [string, string][]) =>
({ identifiers }) as DeviceRegistryEntry;
describe("getNodeIdFromDevice", () => {
it("reads the node ID from the identifier", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-25"]]))
).toBe(25);
});
it("reads the node ID from the extended identifier", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-25-798:5:20"]]))
).toBe(25);
});
it("returns multi-digit node IDs in full", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-108"]]))
).toBe(108);
});
it("ignores provisioning entries, whose DSK blocks parse as numbers", () => {
expect(
getNodeIdFromDevice(
mockDevice([
[
"zwave_js",
"provision_50285-00042-09924-30691-15973-33711-04005-03623",
],
])
)
).toBeUndefined();
});
it("ignores identifiers of other integrations", () => {
expect(
getNodeIdFromDevice(mockDevice([["mqtt", "3245146787-25"]]))
).toBeUndefined();
});
it("picks the Z-Wave identifier when a device has several", () => {
expect(
getNodeIdFromDevice(
mockDevice([
["matter", "some-other-id"],
["zwave_js", "3245146787-14"],
])
)
).toBe(14);
});
it("returns undefined when there is no node ID", () => {
expect(getNodeIdFromDevice(mockDevice([]))).toBeUndefined();
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787"]]))
).toBeUndefined();
});
});
@@ -6,8 +6,6 @@ import type {
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
findDevicesOverCap,
getSankeyDeviceSections,
groupSankeyDevicesByFloorAndArea,
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
@@ -83,86 +81,6 @@ describe("getSankeyDeviceSections", () => {
});
});
describe("findDevicesOverCap", () => {
// Values keyed by node id, hierarchy by included_in_stat.
const overCapOpts = (
values: Record<string, number>,
parents: Record<string, string>,
// No default: passing `undefined` must mean "no cap", not "fall back to 3".
maxDevices: number | undefined,
rendered = Object.keys(values)
) => {
const list: DeviceConsumptionEnergyPreference[] = Object.keys(values).map(
(id) => ({ stat_consumption: id, included_in_stat: parents[id] })
);
const renderedIds = new Set(rendered);
return {
devices: list,
maxDevices,
rootNodeId: "home",
renderedIds,
deviceValues: new Map(Object.entries(values)),
getId: (device: DeviceConsumptionEnergyPreference) =>
device.stat_consumption,
getEffectiveParent: (device: DeviceConsumptionEnergyPreference) => {
let current = device.included_in_stat;
for (let hops = 0; current && hops < list.length; hops++) {
if (renderedIds.has(current)) {
return current;
}
current = parents[current];
}
return undefined;
},
};
};
it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
"groups nothing for a cap of %s",
(maxDevices) => {
expect(
findDevicesOverCap(
overCapOpts({ a: 5, b: 4, c: 3, d: 2 }, {}, maxDevices)
)
).toEqual(new Set());
}
);
it("groups the smallest children of an over-cap parent", () => {
expect(
findDevicesOverCap(overCapOpts({ a: 5, b: 4, c: 3, d: 2, e: 1 }, {}, 3))
).toEqual(new Set(["d", "e"]));
});
it("groups a whole subtree, not just the parent", () => {
expect(
findDevicesOverCap(
overCapOpts(
{ a: 50, b: 40, c: 30, small: 10, child: 9 },
{ child: "small" },
3
)
)
).toEqual(new Set(["small", "child", "c"]));
});
it("counts only rendered devices", () => {
// four devices, three rendered, so a cap of three does not fire
expect(
findDevicesOverCap(
overCapOpts({ a: 5, b: 4, c: 3, tiny: 0.001 }, {}, 3, ["a", "b", "c"])
)
).toEqual(new Set());
});
it("terminates on a cyclic parent chain", () => {
// both devices parent each other, so neither is reachable from the root
expect(
findDevicesOverCap(overCapOpts({ a: 5, b: 4 }, { a: "b", b: "a" }, 1))
).toEqual(new Set());
});
});
describe("buildSankeyDeviceNodes", () => {
it("renders top-level devices and subtracts them from untracked", () => {
const result = buildSankeyDeviceNodes(
@@ -505,385 +423,6 @@ describe("buildSankeyDeviceNodes", () => {
expect(result.deviceNodes).toEqual([]);
expect(result.untrackedConsumption).toBe(10);
});
describe("device count cap", () => {
// Five devices all comfortably above minThreshold, so only the count cap
// can group any of them.
const fiveDevices = () =>
devices(
{ stat_consumption: "a" },
{ stat_consumption: "b" },
{ stat_consumption: "c" },
{ stat_consumption: "d" },
{ stat_consumption: "e" }
);
const fiveValues = { a: 50, b: 40, c: 30, d: 20, e: 10 };
const namedIds = (result: { deviceNodes: SankeyDeviceNode[] }) =>
result.deviceNodes
.filter(
(n) => !n.id.startsWith("other_") && !n.id.startsWith("untracked_")
)
.map((n) => n.id);
it("does not group anything when maxDevices is unset", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: fiveDevices(),
values: fiveValues,
initialUntracked: 150,
})
);
expect(namedIds(result)).toEqual(["a", "b", "c", "d", "e"]);
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
false
);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("does nothing at exactly the cap", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "a" },
{ stat_consumption: "b" },
{ stat_consumption: "c" }
),
values: { a: 50, b: 40, c: 30 },
maxDevices: 3,
initialUntracked: 120,
})
);
expect(namedIds(result)).toEqual(["a", "b", "c"]);
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
false
);
});
it("groups the smallest above the cap, keeping the cap many by name", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: fiveDevices(),
values: fiveValues,
maxDevices: 3,
initialUntracked: 150,
})
);
// the cap budgets named devices; Other is overhead on top
expect(namedIds(result)).toEqual(["a", "b", "c"]);
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBeCloseTo(30, 10);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("never demotes exactly one device, which would render it by name", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "a" },
{ stat_consumption: "b" },
{ stat_consumption: "c" },
{ stat_consumption: "d" }
),
values: { a: 40, b: 30, c: 20, d: 10 },
maxDevices: 3,
initialUntracked: 100,
})
);
expect(namedIds(result)).toEqual(["a", "b"]);
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBeCloseTo(30, 10);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("demotes a whole subtree so children never re-parent or double count", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "g" },
{ stat_consumption: "a", included_in_stat: "g" },
{ stat_consumption: "c", included_in_stat: "a" },
{ stat_consumption: "b1", included_in_stat: "g" },
{ stat_consumption: "b2", included_in_stat: "g" },
{ stat_consumption: "b3", included_in_stat: "g" },
{ stat_consumption: "b4", included_in_stat: "g" }
),
values: { g: 100, a: 10, c: 9, b1: 30, b2: 25, b3: 20, b4: 8 },
maxDevices: 3,
initialUntracked: 100,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"g",
"b1",
"b2",
"b3",
"other_g",
"untracked_g",
]);
// a's value already contains c, so Other is 10 + 8 and not 27
const other = result.deviceNodes.find((n) => n.id === "other_g");
expect(other?.value).toBeCloseTo(18, 10);
expect(result.deviceNodes.some((n) => n.id === "c")).toBe(false);
const untracked = result.deviceNodes.find((n) => n.id === "untracked_g");
expect(untracked?.value).toBeCloseTo(7, 10);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("ranks by raw value, so a parent is never demoted before its own child", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "d" },
{ stat_consumption: "e", included_in_stat: "d" },
{ stat_consumption: "f" },
{ stat_consumption: "h" },
{ stat_consumption: "i" }
),
values: { d: 50, e: 45, f: 10, h: 40, i: 30 },
maxDevices: 3,
initialUntracked: 130,
})
);
// Ranking on value-excluding-children would demote d (50 - 45 = 5) while
// keeping e, counting e both on its own and inside d's rolled-up value.
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"d",
"e",
"h",
"other_home",
"untracked_d",
]);
expect(result.parentLinks.e).toBe("d");
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBeCloseTo(40, 10);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("applies the cap per parent and links Other to that parent", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p" },
{ stat_consumption: "c1", included_in_stat: "p" },
{ stat_consumption: "c2", included_in_stat: "p" },
{ stat_consumption: "c3", included_in_stat: "p" },
{ stat_consumption: "c4", included_in_stat: "p" },
{ stat_consumption: "c5", included_in_stat: "p" },
{ stat_consumption: "r1" },
{ stat_consumption: "r2" }
),
values: {
p: 100,
c1: 30,
c2: 25,
c3: 20,
c4: 15,
c5: 10,
r1: 50,
r2: 40,
},
maxDevices: 3,
initialUntracked: 190,
})
);
// p is over the cap; the three root-level nodes are exactly at it
expect(namedIds(result)).toEqual(["p", "c1", "c2", "c3", "r1", "r2"]);
const other = result.deviceNodes.find((n) => n.id === "other_p");
expect(other?.value).toBeCloseTo(25, 10);
expect(result.parentLinks.other_p).toBe("p");
expect(result.links).toContainEqual({ source: "p", target: "other_p" });
expect(result.deviceNodes.some((n) => n.id === "other_home")).toBe(false);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("counts only rendered children, not ones already below the threshold", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p" },
{ stat_consumption: "c1", included_in_stat: "p" },
{ stat_consumption: "c2", included_in_stat: "p" },
{ stat_consumption: "c3", included_in_stat: "p" },
{ stat_consumption: "s1", included_in_stat: "p" },
{ stat_consumption: "s2", included_in_stat: "p" },
{ stat_consumption: "s3", included_in_stat: "p" }
),
values: {
p: 100,
c1: 30,
c2: 25,
c3: 20,
s1: 0.001,
s2: 0.002,
s3: 0.003,
},
maxDevices: 3,
initialUntracked: 100,
})
);
// three rendered children is at the cap, so no demotion happens
expect(namedIds(result)).toEqual(["p", "c1", "c2", "c3"]);
const other = result.deviceNodes.find((n) => n.id === "other_p");
expect(other?.value).toBeCloseTo(0.006, 10);
});
it("terminates on a cyclic included_in_stat and leaves it unchanged", () => {
const cyclic = () =>
cumulativeOpts({
devices: devices(
{ stat_consumption: "a", included_in_stat: "b" },
{ stat_consumption: "b", included_in_stat: "a" }
),
values: { a: 5, b: 4 },
initialUntracked: 10,
});
// A cycle member always has a rendered parent inside the cycle, so it is
// never reachable from the root and the cap cannot touch it.
expect(
buildSankeyDeviceNodes({ ...cyclic(), maxDevices: 1 })
).toStrictEqual(buildSankeyDeviceNodes(cyclic()));
});
it("treats a zero or non-finite cap as no cap", () => {
[0, Number.NaN, Number.POSITIVE_INFINITY].forEach((maxDevices) => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: fiveDevices(),
values: fiveValues,
maxDevices,
initialUntracked: 150,
})
);
expect(namedIds(result)).toEqual(["a", "b", "c", "d", "e"]);
});
});
it("does not count devices without a node id (instantaneous cards)", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "a", stat_rate: "ra" },
{ stat_consumption: "b", stat_rate: "rb" },
{ stat_consumption: "c", stat_rate: "rc" },
{ stat_consumption: "d" }
),
values: { ra: 30, rb: 25, rc: 20 },
getId: (device) => device.stat_rate,
maxDevices: 3,
initialUntracked: 75,
})
);
// d has no stat_rate, so the three rendered nodes are at the cap
expect(namedIds(result)).toEqual(["ra", "rb", "rc"]);
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
false
);
});
it("ceils the capped Other value but still subtracts the raw total", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: fiveDevices(),
values: { ...fiveValues, e: 10.5 },
maxDevices: 3,
ceilOtherValue: true,
initialUntracked: 150.5,
})
);
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBe(31);
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
});
it("breaks value ties on declaration order, stably across runs", () => {
const opts = () =>
cumulativeOpts({
devices: fiveDevices(),
values: { a: 50, b: 20, c: 20, d: 20, e: 10 },
maxDevices: 3,
initialUntracked: 120,
});
expect(namedIds(buildSankeyDeviceNodes(opts()))).toEqual(["a", "c", "d"]);
expect(namedIds(buildSankeyDeviceNodes(opts()))).toEqual(["a", "c", "d"]);
});
// The documented promise is "more than 20 devices start grouping", so pin
// the boundary against the real default rather than a test-local cap.
it.each([
[DEFAULT_MAX_SANKEY_DEVICES, DEFAULT_MAX_SANKEY_DEVICES, false],
// one over the cap still demotes two, because a lone demoted device would
// be rendered by name and void the cap
[DEFAULT_MAX_SANKEY_DEVICES + 1, DEFAULT_MAX_SANKEY_DEVICES - 1, true],
[DEFAULT_MAX_SANKEY_DEVICES + 2, DEFAULT_MAX_SANKEY_DEVICES, true],
[DEFAULT_MAX_SANKEY_DEVICES + 12, DEFAULT_MAX_SANKEY_DEVICES, true],
])(
"with %i devices at the default cap renders %i named nodes (grouped: %s)",
(deviceCount, expectedNamed, expectOther) => {
const list = Array.from({ length: deviceCount }, (_, i) => ({
stat_consumption: `d${i}`,
}));
const values = Object.fromEntries(
list.map((d, i) => [d.stat_consumption, 10 + i])
);
const total = Object.values(values).reduce((s, v) => s + v, 0);
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: list,
values,
maxDevices: DEFAULT_MAX_SANKEY_DEVICES,
initialUntracked: total,
})
);
expect(namedIds(result)).toHaveLength(expectedNamed);
expect(result.deviceNodes.some((n) => n.id === "other_home")).toBe(
expectOther
);
// grouping never changes the total that comes off the root
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
}
);
it("keeps 32 circuit clamps readable at the default cap", () => {
const clamps = Array.from({ length: 32 }, (_, i) => ({
stat_consumption: `clamp_${i}`,
}));
const values = Object.fromEntries(
clamps.map((clamp, i) => [clamp.stat_consumption, 20 + i])
);
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: clamps,
values,
maxDevices: DEFAULT_MAX_SANKEY_DEVICES,
initialUntracked: 1200,
})
);
expect(namedIds(result)).toHaveLength(DEFAULT_MAX_SANKEY_DEVICES);
expect(result.deviceNodes).toHaveLength(DEFAULT_MAX_SANKEY_DEVICES + 1);
// the 12 smallest clamps, values 20 through 31
const other = result.deviceNodes.find((n) => n.id === "other_home");
expect(other?.value).toBeCloseTo(306, 10);
// flow conservation: every device node plus untracked accounts for home
const { nodes } = buildSankeyLayout({
hass: createMockHass(),
computedStyle,
localize: mockLocalize,
deviceNodes: result.deviceNodes,
parentLinks: result.parentLinks,
rootNodeId: "home",
groupByFloor: false,
groupByArea: false,
untrackedConsumption: result.untrackedConsumption,
untrackedFloor: 0,
});
const deviceTotal = nodes
.filter((n) => n.index === 4)
.reduce((sum, n) => sum + n.value, 0);
expect(deviceTotal).toBeCloseTo(1200, 10);
});
});
});
describe("groupSankeyDevicesByFloorAndArea", () => {
+562 -507
View File
File diff suppressed because it is too large Load Diff