mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-13 04:36:53 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8af5908682 | |||
| 60e95b886c | |||
| 0385ca8076 | |||
| 02c65fc8cb | |||
| 49290d5c83 | |||
| 08aff3bfd7 | |||
| 455fa45b9c |
@@ -1,3 +1,4 @@
|
||||
/* global process */
|
||||
// Tasks to generate entry HTML
|
||||
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ const SAFARI_TO_MACOS = {
|
||||
16: [11, 0, 0],
|
||||
17: [12, 0, 0],
|
||||
18: [13, 0, 0],
|
||||
26: [26, 0, 0],
|
||||
};
|
||||
|
||||
const getCommonTemplateVars = () => {
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import {
|
||||
mdiBattery,
|
||||
mdiBattery10,
|
||||
mdiBattery20,
|
||||
mdiBattery30,
|
||||
mdiBattery40,
|
||||
mdiBattery50,
|
||||
mdiBattery60,
|
||||
mdiBattery70,
|
||||
mdiBattery80,
|
||||
mdiBattery90,
|
||||
mdiBatteryAlertVariantOutline,
|
||||
mdiBatteryUnknown,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
|
||||
const BATTERY_ICONS = {
|
||||
@@ -12,6 +26,18 @@ const BATTERY_ICONS = {
|
||||
90: "mdi:battery-90",
|
||||
100: "mdi:battery",
|
||||
};
|
||||
const BATTERY_ICON_PATHS = {
|
||||
10: mdiBattery10,
|
||||
20: mdiBattery20,
|
||||
30: mdiBattery30,
|
||||
40: mdiBattery40,
|
||||
50: mdiBattery50,
|
||||
60: mdiBattery60,
|
||||
70: mdiBattery70,
|
||||
80: mdiBattery80,
|
||||
90: mdiBattery90,
|
||||
100: mdiBattery,
|
||||
};
|
||||
const BATTERY_CHARGING_ICONS = {
|
||||
10: "mdi:battery-charging-10",
|
||||
20: "mdi:battery-charging-20",
|
||||
@@ -57,3 +83,15 @@ export const batteryLevelIcon = (
|
||||
}
|
||||
return BATTERY_ICONS[batteryRound];
|
||||
};
|
||||
|
||||
export const batteryLevelIconPath = (batteryLevel: number | string): string => {
|
||||
const batteryValue = Number(batteryLevel);
|
||||
if (isNaN(batteryValue)) {
|
||||
return mdiBatteryUnknown;
|
||||
}
|
||||
if (batteryValue <= 5) {
|
||||
return mdiBatteryAlertVariantOutline;
|
||||
}
|
||||
const batteryRound = Math.round(batteryValue / 10) * 10;
|
||||
return BATTERY_ICON_PATHS[batteryRound];
|
||||
};
|
||||
|
||||
@@ -13,7 +13,9 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
import { formatDate } from "../../common/datetime/format_date";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
|
||||
import {
|
||||
formatNumber,
|
||||
getNumberFormatOptions,
|
||||
@@ -241,6 +243,8 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
private _renderTooltip = (params: any) => {
|
||||
const rendered: Record<string, boolean> = {};
|
||||
const chartIsBar = this.chartType.startsWith("bar");
|
||||
const period = this.period;
|
||||
const unit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||
: "";
|
||||
@@ -252,8 +256,67 @@ export class StatisticsChart extends LitElement {
|
||||
const statisticId = this._statisticIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
const entry = this.hass.entities[statisticId];
|
||||
// max series can have 3 values, as the second value is the max-min to form a band
|
||||
const rawValue = String(param.value[2] ?? param.value[1]);
|
||||
let rawValue: string;
|
||||
let rawTime: string;
|
||||
if (chartIsBar) {
|
||||
// For bar charts value is always second value.
|
||||
rawValue = String(param.value[1]);
|
||||
// Time value is third value (un-shifted date) if given, otherwise first value
|
||||
let startTime: Date;
|
||||
let endTime: Date | undefined;
|
||||
if (param.value[2]) {
|
||||
startTime = new Date(param.value[2]);
|
||||
if (param.value[3]) {
|
||||
endTime = new Date(param.value[3]);
|
||||
}
|
||||
} else {
|
||||
startTime = new Date(param.value[0]);
|
||||
}
|
||||
if (
|
||||
period === "year" ||
|
||||
period === "month" ||
|
||||
period === "week" ||
|
||||
period === "day"
|
||||
) {
|
||||
// For year/month/day periods, show only the date
|
||||
rawTime =
|
||||
formatDate(startTime, this.hass.locale, this.hass.config) +
|
||||
(endTime && period !== "day"
|
||||
? ` – ${formatDate(
|
||||
endTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}`
|
||||
: "") +
|
||||
"<br>";
|
||||
} else {
|
||||
// For other time periods, include time in render, and optionally show range
|
||||
// if we have an end time.
|
||||
rawTime =
|
||||
formatDateTimeWithSeconds(
|
||||
startTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) +
|
||||
(endTime
|
||||
? ` – ${formatTimeWithSeconds(
|
||||
endTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}`
|
||||
: "") +
|
||||
"<br>";
|
||||
}
|
||||
} else {
|
||||
// For lines max series can have 3 values, as the second value is the max-min to form a band
|
||||
rawValue = String(param.value[2] ?? param.value[1]);
|
||||
// Time value is always first value
|
||||
rawTime = `${formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)} <br>`;
|
||||
}
|
||||
|
||||
const options = getNumberFormatOptions(stateObj, entry) ?? {
|
||||
maximumFractionDigits: 2,
|
||||
@@ -265,14 +328,7 @@ export class StatisticsChart extends LitElement {
|
||||
options
|
||||
)}${unit}`;
|
||||
|
||||
const time =
|
||||
index === 0
|
||||
? formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) + "<br>"
|
||||
: "";
|
||||
const time = index === 0 ? rawTime : "";
|
||||
return `${time}${param.marker} ${param.seriesName}: ${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -511,33 +567,53 @@ export class StatisticsChart extends LitElement {
|
||||
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
const statLegendData: typeof legendData = [];
|
||||
|
||||
// Place bars at centre of their specified time range if this is a bar chart
|
||||
// and the period is 5minute or hour.
|
||||
const centerBars =
|
||||
chartType === "bar" &&
|
||||
(this.period === "5minute" || this.period === "hour");
|
||||
|
||||
const pushData = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
start: Date, // Data point start time
|
||||
end: Date, // Data point end time
|
||||
limit: Date, // Limit for end time (e.g. now)
|
||||
dataValues: (number | null)[][]
|
||||
) => {
|
||||
if (!dataValues.length) return;
|
||||
if (start > end) {
|
||||
// Limit for time range is lesser of overall limit and data point end
|
||||
limit = end.getTime() < limit.getTime() ? end : limit;
|
||||
if (start.getTime() > limit.getTime()) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
statDataSets.forEach((d, i) => {
|
||||
if (
|
||||
chartType === "line" &&
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
if (chartType === "line") {
|
||||
if (
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
}
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
} else {
|
||||
let time = start;
|
||||
if (centerBars) {
|
||||
// If centering bars, set the time to the midpoint between start and end instead
|
||||
// of the start time.
|
||||
time = new Date((start.getTime() + end.getTime()) / 2);
|
||||
}
|
||||
// Data value should always be a scalar for bar charts. Pass in
|
||||
// real start time as extra value to allow formatting tooltip.
|
||||
d.data!.push([time, dataValues[i][0]!, start, end]);
|
||||
}
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
});
|
||||
prevValues = dataValues;
|
||||
prevEndTime = end;
|
||||
prevEndTime = limit;
|
||||
};
|
||||
|
||||
let color = colors[statistic_id];
|
||||
@@ -697,11 +773,7 @@ export class StatisticsChart extends LitElement {
|
||||
dataValues.push(val);
|
||||
});
|
||||
if (!this._hiddenStats.has(statistic_id)) {
|
||||
pushData(
|
||||
startDate,
|
||||
endDate.getTime() < endTime.getTime() ? endDate : endTime,
|
||||
dataValues
|
||||
);
|
||||
pushData(startDate, endDate, endTime, dataValues);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ const isOn = (stateObj?: HassEntity) =>
|
||||
!STATES_OFF.includes(stateObj.state) &&
|
||||
!isUnavailableState(stateObj.state);
|
||||
|
||||
/**
|
||||
* @element ha-entity-toggle
|
||||
*
|
||||
* @cssprop --ha-entity-toggle-switch-width - Width of the switch track. Defaults to `38px`.
|
||||
* @cssprop --ha-entity-toggle-switch-size - Height of the switch track. Defaults to `20px`.
|
||||
* @cssprop --ha-entity-toggle-switch-thumb-size - Size of the switch thumb. Defaults to `14px`.
|
||||
*/
|
||||
|
||||
@customElement("ha-entity-toggle")
|
||||
export class HaEntityToggle extends LitElement {
|
||||
// hass is not a property so that we only re-render on stateObj changes
|
||||
@@ -165,9 +173,9 @@ export class HaEntityToggle extends LitElement {
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-switch {
|
||||
--ha-switch-width: 38px;
|
||||
--ha-switch-size: 20px;
|
||||
--ha-switch-thumb-size: 14px;
|
||||
--ha-switch-width: var(--ha-entity-toggle-switch-width, 38px);
|
||||
--ha-switch-size: var(--ha-entity-toggle-switch-size, 20px);
|
||||
--ha-switch-thumb-size: var(--ha-entity-toggle-switch-thumb-size, 14px);
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
|
||||
import "../../ha-dialog";
|
||||
import "../../ha-adaptive-dialog";
|
||||
import "../../ha-dialog-header";
|
||||
import "../../ha-icon-button";
|
||||
import "../../ha-icon-next";
|
||||
@@ -153,7 +153,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
!this._entitySourcesLoaded;
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
<ha-adaptive-dialog
|
||||
.open=${this._opened}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.components.target-picker.target_details"
|
||||
@@ -187,7 +187,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
`}
|
||||
</ha-list-base>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -308,7 +308,12 @@ export class HaTracePathDetails extends LitElement {
|
||||
? this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.no_variables_changed"
|
||||
)
|
||||
: html`<pre>${dump(trace.changed_variables).trimEnd()}</pre>`}
|
||||
: html`<ha-code-editor
|
||||
read-only
|
||||
dir="ltr"
|
||||
.hass=${this.hass}
|
||||
.value=${dump(trace.changed_variables).trimEnd()}
|
||||
></ha-code-editor>`}
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,7 @@ export interface BatterySourceTypeEnergyPreference {
|
||||
stat_energy_to: string;
|
||||
stat_rate?: string; // always available if power_config is set
|
||||
power_config?: PowerConfig;
|
||||
stat_soc?: string;
|
||||
}
|
||||
export interface GasSourceTypeEnergyPreference {
|
||||
type: "gas";
|
||||
|
||||
+4
-1
@@ -866,10 +866,13 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
undefined
|
||||
);
|
||||
|
||||
const filteredFloors = this._floorAreas.filter(
|
||||
({ id, areas }) => id !== undefined && areas.length
|
||||
);
|
||||
this._floorAreas.forEach((floor) => {
|
||||
this._entries[floor.id || `floor${TARGET_SEPARATOR}`] = {
|
||||
// auto expand if only one floor is present
|
||||
open: this._floorAreas.length === 1,
|
||||
open: filteredFloors.length === 1 && filteredFloors[0].id === floor.id,
|
||||
areas: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const energyUnitClasses = ["energy"];
|
||||
const socStatisticsUnits = ["%"];
|
||||
const socDeviceClass = "battery";
|
||||
|
||||
@customElement("dialog-energy-battery-settings")
|
||||
export class DialogEnergyBatterySettings
|
||||
@@ -179,6 +181,21 @@ export class DialogEnergyBatterySettings
|
||||
@power-config-changed=${this._handlePowerConfigChanged}
|
||||
></ha-energy-power-config>
|
||||
|
||||
<ha-statistic-picker
|
||||
.hass=${this.hass}
|
||||
.helpMissingEntityUrl=${energyStatisticHelpUrl}
|
||||
.value=${this._source.stat_soc}
|
||||
.includeStatisticsUnitOfMeasurement=${socStatisticsUnits}
|
||||
.includeDeviceClass=${socDeviceClass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.state_of_charge"
|
||||
)}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.dialog.state_of_charge_helper"
|
||||
)}
|
||||
@value-changed=${this._statisticSocChanged}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@@ -231,6 +248,13 @@ export class DialogEnergyBatterySettings
|
||||
this._powerConfig = ev.detail.powerConfig;
|
||||
}
|
||||
|
||||
private _statisticSocChanged(ev: ValueChangedEvent<string>) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
stat_soc: ev.detail.value || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
try {
|
||||
const source: BatterySourceTypeEnergyPreference = {
|
||||
@@ -244,6 +268,10 @@ export class DialogEnergyBatterySettings
|
||||
source.power_config = { ...this._powerConfig };
|
||||
}
|
||||
|
||||
if (this._source!.stat_soc) {
|
||||
source.stat_soc = this._source!.stat_soc;
|
||||
}
|
||||
|
||||
await this._params!.saveCallback(source);
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
@@ -256,7 +284,8 @@ export class DialogEnergyBatterySettings
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
ha-statistic-picker,
|
||||
ha-energy-power-config {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import "./ha-domain-integrations";
|
||||
import "./ha-integration-list-item";
|
||||
import type { AddIntegrationDialogParams } from "./show-add-integration-dialog";
|
||||
import { showYamlIntegrationDialog } from "./show-add-integration-dialog";
|
||||
import { showSingleConfigEntryWarning } from "./show-single-config-entry-warning";
|
||||
|
||||
export interface IntegrationListItem {
|
||||
name: string;
|
||||
@@ -710,21 +711,8 @@ class AddIntegrationDialog extends LitElement {
|
||||
});
|
||||
if (configEntries.length > 0) {
|
||||
this.closeDialog();
|
||||
const localize = await this.hass.loadBackendTranslation(
|
||||
"title",
|
||||
integration.name
|
||||
);
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.single_config_entry_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.single_config_entry",
|
||||
{
|
||||
integration_name: domainToName(localize, integration.name),
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
showSingleConfigEntryWarning(this, { domain: integration.domain });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-button";
|
||||
import { internationalizationContext } from "../../../data/context";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import { DialogMixin } from "../../../dialogs/dialog-mixin";
|
||||
import type { SingleConfigEntryWarningDialogParams } from "./show-single-config-entry-warning";
|
||||
|
||||
@customElement("dialog-single-config-entry-warning")
|
||||
class DialogSingleConfigEntryWarning extends DialogMixin<SingleConfigEntryWarningDialogParams>(
|
||||
LitElement
|
||||
) {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state() private _backendLocalize?: LocalizeFunc;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._loadBackendLocalize();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._backendLocalize) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
.headerTitle=${this._i18n.localize(
|
||||
"ui.panel.config.integrations.config_flow.single_config_entry_title"
|
||||
)}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.integrations.config_flow.single_config_entry",
|
||||
{
|
||||
integration_name: html`<b
|
||||
>${domainToName(this._backendLocalize, this.params.domain)}</b
|
||||
>`,
|
||||
}
|
||||
)}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this._i18n.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.href=${`/config/integrations/integration/${this.params.domain}`}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.integrations.config_flow.show_integration"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _loadBackendLocalize() {
|
||||
if (!this.params) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._backendLocalize = await this._i18n.loadBackendTranslation(
|
||||
"title",
|
||||
this.params.domain
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-single-config-entry-warning": DialogSingleConfigEntryWarning;
|
||||
}
|
||||
}
|
||||
@@ -621,7 +621,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
</ha-button>
|
||||
`
|
||||
: nothing}
|
||||
${this._manifest?.integration_type !== "hardware"
|
||||
${this._manifest?.integration_type !== "hardware" &&
|
||||
(!this._manifest?.single_config_entry ||
|
||||
(normalData.length === 0 && attentionData.length === 0))
|
||||
? html`<ha-button
|
||||
.appearance=${canAddDevice ? "filled" : "accent"}
|
||||
@click=${this._addIntegration}
|
||||
@@ -1235,30 +1237,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this._manifest?.single_config_entry) {
|
||||
const entries = this._domainConfigEntries(
|
||||
this.domain,
|
||||
this._extraConfigEntries || this.configEntries
|
||||
);
|
||||
if (entries.length > 0) {
|
||||
const localize = await this.hass.loadBackendTranslation(
|
||||
"title",
|
||||
this._manifest.name
|
||||
);
|
||||
await showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.single_config_entry_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.single_config_entry",
|
||||
{
|
||||
integration_name: domainToName(localize, this._manifest.name),
|
||||
}
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
showAddIntegrationDialog(this, {
|
||||
domain: this.domain,
|
||||
navigateToResult: true,
|
||||
|
||||
@@ -69,6 +69,7 @@ import "./ha-integration-card";
|
||||
import type { HaIntegrationCard } from "./ha-integration-card";
|
||||
import "./ha-integration-overflow-menu";
|
||||
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
|
||||
import { showSingleConfigEntryWarning } from "./show-single-config-entry-warning";
|
||||
|
||||
export interface ConfigEntryExtended extends Omit<ConfigEntry, "entry_id"> {
|
||||
entry_id?: string;
|
||||
@@ -914,21 +915,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
if (integration.single_config_entry) {
|
||||
const configEntries = await getConfigEntries(this.hass, { domain });
|
||||
if (configEntries.length > 0) {
|
||||
const localize = await this.hass.loadBackendTranslation(
|
||||
"title",
|
||||
integration.name
|
||||
);
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.single_config_entry_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.integrations.config_flow.single_config_entry",
|
||||
{
|
||||
integration_name: domainToName(localize, integration.name!),
|
||||
}
|
||||
),
|
||||
});
|
||||
showSingleConfigEntryWarning(this, { domain });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LitElement } from "lit";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
|
||||
export interface SingleConfigEntryWarningDialogParams {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export const showSingleConfigEntryWarning = (
|
||||
element: LitElement,
|
||||
params: SingleConfigEntryWarningDialogParams
|
||||
) =>
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-single-config-entry-warning",
|
||||
dialogParams: params,
|
||||
dialogImport: () => import("./dialog-single-config-entry-warning"),
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { batteryLevelIconPath } from "../../../../common/entity/battery_icon";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
@@ -37,6 +38,9 @@ import type { EnergyDistributionCardConfig } from "../types";
|
||||
|
||||
const CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
|
||||
const periodIncludesNow = (data: EnergyData): boolean =>
|
||||
!data.end || data.end.getTime() >= Date.now();
|
||||
|
||||
@customElement("hui-energy-distribution-card")
|
||||
class HuiEnergyDistrubutionCard
|
||||
extends SubscribeMixin(LitElement)
|
||||
@@ -100,14 +104,34 @@ class HuiEnergyDistrubutionCard
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
return (
|
||||
if (
|
||||
hasConfigChanged(this, changedProps) ||
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass") ||
|
||||
(!!this._data?.co2SignalEntity &&
|
||||
this.hass.states[this._data.co2SignalEntity] !==
|
||||
changedProps.get("hass").states[this._data.co2SignalEntity])
|
||||
);
|
||||
!changedProps.has("hass")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const oldStates = changedProps.get("hass").states;
|
||||
if (
|
||||
this._data?.co2SignalEntity &&
|
||||
this.hass.states[this._data.co2SignalEntity] !==
|
||||
oldStates[this._data.co2SignalEntity]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (this._data && periodIncludesNow(this._data)) {
|
||||
const batteries = energySourcesByType(this._data.prefs).battery;
|
||||
if (
|
||||
batteries?.some(
|
||||
(source) =>
|
||||
source.stat_soc &&
|
||||
this.hass.states[source.stat_soc] !== oldStates[source.stat_soc]
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected willUpdate() {
|
||||
@@ -174,10 +198,29 @@ class HuiEnergyDistrubutionCard
|
||||
|
||||
let totalBatteryIn: number | null = null;
|
||||
let totalBatteryOut: number | null = null;
|
||||
let batteryIconPath = mdiBatteryHigh;
|
||||
|
||||
if (hasBattery) {
|
||||
totalBatteryIn = summedData.total.to_battery ?? 0;
|
||||
totalBatteryOut = summedData.total.from_battery ?? 0;
|
||||
|
||||
// The SOC reflects the current battery level, so it only matches the
|
||||
// card's data when the selected period extends to now. For historical
|
||||
// periods (yesterday, last week, ...) fall back to the generic icon.
|
||||
if (periodIncludesNow(this._data)) {
|
||||
const socValues = types
|
||||
.battery!.map((source) =>
|
||||
source.stat_soc
|
||||
? Number(this.hass.states[source.stat_soc]?.state)
|
||||
: NaN
|
||||
)
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (socValues.length) {
|
||||
const averageSoc =
|
||||
socValues.reduce((sum, value) => sum + value, 0) / socValues.length;
|
||||
batteryIconPath = batteryLevelIconPath(averageSoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let returnedToGrid: number | null = null;
|
||||
@@ -569,7 +612,7 @@ class HuiEnergyDistrubutionCard
|
||||
${hasBattery
|
||||
? html` <div class="circle-container battery">
|
||||
<div class="circle">
|
||||
<ha-svg-icon .path=${mdiBatteryHigh}></ha-svg-icon>
|
||||
<ha-svg-icon .path=${batteryIconPath}></ha-svg-icon>
|
||||
<span class="battery-in">
|
||||
<ha-svg-icon
|
||||
class="small"
|
||||
|
||||
@@ -160,14 +160,10 @@ class HuiWaterFlowSankeyCard
|
||||
// When there are no source meters, pre-compute total device flow so the
|
||||
// home node has the correct value (sum of all device consumption) rather
|
||||
// than 0. This avoids a broken sankey where the root node has value=0
|
||||
// while its children have positive values. Skip sub-trackers so the
|
||||
// total only reflects top-level devices and we don't double-count.
|
||||
// while its children have positive values.
|
||||
let totalDeviceFlow = 0;
|
||||
if (waterSources.length === 0) {
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (device.included_in_stat) {
|
||||
return;
|
||||
}
|
||||
if (device.stat_rate) {
|
||||
totalDeviceFlow += this._getCurrentFlowRate(device.stat_rate);
|
||||
}
|
||||
|
||||
@@ -123,14 +123,9 @@ class HuiWaterSankeyCard
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
|
||||
// Sum only top-level devices. Devices with `included_in_stat` are already
|
||||
// counted inside their parent stat; adding them again would double-count
|
||||
// and push the home total above the source meter.
|
||||
// Calculate total water consumption from all sources or devices
|
||||
const totalDownstreamConsumption = prefs.device_consumption_water.reduce(
|
||||
(total, device) => {
|
||||
if (device.included_in_stat) {
|
||||
return total;
|
||||
}
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
|
||||
@@ -4185,6 +4185,8 @@
|
||||
"energy_helper_out": "Pick a sensor that measures the electricity flowing out of the battery in either of {unit}.",
|
||||
"energy_into_battery": "Energy charged into the battery",
|
||||
"energy_out_of_battery": "Energy discharged from the battery",
|
||||
"state_of_charge": "Battery state of charge sensor",
|
||||
"state_of_charge_helper": "Sensor reporting battery state of charge as %.",
|
||||
"power": "Battery power",
|
||||
"power_helper": "Pick a sensor which measures the electricity flowing into and out of the battery in either of {unit}. Positive values indicate discharging the battery, negative values indicate charging the battery.",
|
||||
"sensor_type": "Type of power measurement",
|
||||
@@ -6908,6 +6910,7 @@
|
||||
"supported_hardware": "supported hardware",
|
||||
"proceed": "Proceed",
|
||||
"single_config_entry_title": "This integration allows only one configuration",
|
||||
"show_integration": "Show integration",
|
||||
"single_config_entry": "{integration_name} supports only one configuration. Adding additional ones is not needed."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import {
|
||||
mdiBattery,
|
||||
mdiBattery10,
|
||||
mdiBattery50,
|
||||
mdiBattery90,
|
||||
mdiBatteryAlertVariantOutline,
|
||||
mdiBatteryUnknown,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
batteryIcon,
|
||||
batteryLevelIcon,
|
||||
batteryLevelIconPath,
|
||||
} from "../../../src/common/entity/battery_icon";
|
||||
|
||||
describe("batteryIcon", () => {
|
||||
@@ -43,3 +52,24 @@ describe("batteryLevelIcon", () => {
|
||||
expect(batteryLevelIcon("on")).toBe("mdi:battery-alert");
|
||||
});
|
||||
});
|
||||
|
||||
describe("batteryLevelIconPath", () => {
|
||||
it("rounds to the nearest 10% bucket", () => {
|
||||
expect(batteryLevelIconPath(46)).toBe(mdiBattery50);
|
||||
expect(batteryLevelIconPath(94)).toBe(mdiBattery90);
|
||||
expect(batteryLevelIconPath(95)).toBe(mdiBattery);
|
||||
});
|
||||
|
||||
it("returns the alert path for very low levels", () => {
|
||||
expect(batteryLevelIconPath(0)).toBe(mdiBatteryAlertVariantOutline);
|
||||
expect(batteryLevelIconPath(5)).toBe(mdiBatteryAlertVariantOutline);
|
||||
});
|
||||
|
||||
it("returns the 10% bucket just above the alert threshold", () => {
|
||||
expect(batteryLevelIconPath(6)).toBe(mdiBattery10);
|
||||
});
|
||||
|
||||
it("returns the unknown path for non-numeric input", () => {
|
||||
expect(batteryLevelIconPath("unavailable")).toBe(mdiBatteryUnknown);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user