mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-24 15:50:05 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29b2a01299 | ||
|
|
19cd06bb8c |
@@ -12,13 +12,24 @@ import { getEntityContext } from "./context/get_entity_context";
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
export const ENTITY_NAME_TYPES = [
|
||||
"entity",
|
||||
"device",
|
||||
"parent_device",
|
||||
"area",
|
||||
"floor",
|
||||
] as const;
|
||||
|
||||
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: "entity" | "device" | "area" | "floor";
|
||||
type: EntityNameType;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, area, floor } = getEntityContext(
|
||||
const { device, parentDevice, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
@@ -105,6 +116,8 @@ export const computeEntityNameList = (
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "parent_device":
|
||||
return parentDevice ? computeDeviceName(parentDevice) : undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
device: DeviceRegistryEntry | null;
|
||||
parentDevice: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export const getEntityContext = (
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
parentDevice: parentDevice || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
|
||||
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
|
||||
attribute: string
|
||||
) => string;
|
||||
|
||||
export type EntityNameType = "entity" | "device" | "area" | "floor";
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
|
||||
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
@@ -387,6 +388,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
);
|
||||
|
||||
if (context.device) options.add("device");
|
||||
if (context.parentDevice) options.add("parent_device");
|
||||
if (context.area) options.add("area");
|
||||
if (context.floor) options.add("floor");
|
||||
return options;
|
||||
@@ -399,9 +401,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const items = ENTITY_NAME_TYPES.map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
const primary = this.hass.localize(
|
||||
@@ -464,7 +464,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const excludedValues = new Set(
|
||||
this._items
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.filter((item) => KNOWN_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
|
||||
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.parentDevice) {
|
||||
contextItems.push({
|
||||
id: "parent_device_name",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
sorting_label: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.area) {
|
||||
contextItems.push({
|
||||
id: "area_name",
|
||||
|
||||
@@ -52,6 +52,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "statisticId", weight: 3 },
|
||||
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || id;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
|
||||
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
@@ -788,6 +791,15 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (parentDeviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.device-picker.parent_device"
|
||||
),
|
||||
value: parentDeviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.device-picker.device"),
|
||||
|
||||
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -157,6 +167,7 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -62,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
isRTL: boolean,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
const entityName = stateObject
|
||||
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
|
||||
: item;
|
||||
const { area, device } = stateObject
|
||||
const { area, device, parentDevice } = stateObject
|
||||
? getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
@@ -691,10 +691,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: undefined, device: undefined };
|
||||
: { area: undefined, device: undefined, parentDevice: undefined };
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
|
||||
+171
-54
@@ -21,7 +21,6 @@ import {
|
||||
} from "../common/datetime/calc_date";
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
@@ -799,8 +798,8 @@ const clearEnergyCollectionPreferences = (hass: HomeAssistant) => {
|
||||
};
|
||||
|
||||
const scheduleHourlyRefresh = (collection: EnergyCollection) => {
|
||||
if (collection._refreshTimeout) {
|
||||
clearTimeout(collection._refreshTimeout);
|
||||
if (collection._refreshTimeout !== undefined) {
|
||||
window.clearTimeout(collection._refreshTimeout);
|
||||
}
|
||||
|
||||
if (collection._active && (!collection.end || collection.end > new Date())) {
|
||||
@@ -859,18 +858,92 @@ export const getEnergyDefaultPeriodStorageKey = (
|
||||
return `energy-default-period-${key}`;
|
||||
};
|
||||
|
||||
// When does the collection's day period need to roll over to the next day?
|
||||
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
|
||||
// midnight. Otherwise it waits an hour, until the new day's first hourly
|
||||
// statistic exists — rolling over at midnight would show an empty graph.
|
||||
export const getNextEnergyPeriodStart = (
|
||||
// When today's first hourly statistic becomes available (01:00 in the
|
||||
// configured timezone). Rolling the statistics view over at midnight would
|
||||
// show an empty graph.
|
||||
export const getEnergyFirstStatisticAt = (
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"]
|
||||
): Date => addHours(calcDate(now, startOfDay, locale, config), 1);
|
||||
|
||||
// The statistics Energy view shows yesterday until 01:00 so the graph is not
|
||||
// empty. The real-time "Now" view never does this — it has live data.
|
||||
export const shouldFallbackEnergyPeriodToYesterday = (
|
||||
midnightRollover: boolean,
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"]
|
||||
): boolean =>
|
||||
!midnightRollover &&
|
||||
now.getTime() < getEnergyFirstStatisticAt(now, locale, config).getTime();
|
||||
|
||||
// Live day used while a rollover timer is scheduled (today, or the hour-0
|
||||
// yesterday fallback). Custom dates do not use this. If the user already
|
||||
// picked today during hour 0, keep today rather than snapping back.
|
||||
export const getEnergyLiveDayPeriod = (
|
||||
midnightRollover: boolean,
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"],
|
||||
currentStart: Date
|
||||
): { start: Date; end: Date } => {
|
||||
const todayStart = calcDate(now, startOfDay, locale, config);
|
||||
if (
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
midnightRollover,
|
||||
now,
|
||||
locale,
|
||||
config
|
||||
) &&
|
||||
currentStart.getTime() !== todayStart.getTime()
|
||||
) {
|
||||
const yesterday = calcDate(now, addDays, locale, config, -1);
|
||||
return {
|
||||
start: calcDate(yesterday, startOfDay, locale, config),
|
||||
end: calcDate(yesterday, endOfDay, locale, config),
|
||||
};
|
||||
}
|
||||
return {
|
||||
start: todayStart,
|
||||
end: calcDate(now, endOfDay, locale, config),
|
||||
};
|
||||
};
|
||||
|
||||
// When does the collection's day period need to roll over to the next day?
|
||||
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
|
||||
// midnight. Otherwise it waits an hour, until the new day's first hourly
|
||||
// statistic exists — rolling over at midnight would show an empty graph.
|
||||
// Pass `periodStart` when the collection is on a specific day: hour-0
|
||||
// yesterday (and any older stale live day) must wake at today 01:00, not
|
||||
// tomorrow 01:00. Keep tomorrow 01:00 only when the user already picked today.
|
||||
export const getNextEnergyPeriodStart = (
|
||||
midnightRollover: boolean,
|
||||
now: Date,
|
||||
locale: HomeAssistant["locale"],
|
||||
config: HomeAssistant["config"],
|
||||
periodStart?: Date
|
||||
): Date => {
|
||||
const dayEnd = calcDate(now, endOfDay, locale, config);
|
||||
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
|
||||
const todayStart = calcDate(now, startOfDay, locale, config);
|
||||
if (
|
||||
periodStart &&
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
midnightRollover,
|
||||
now,
|
||||
locale,
|
||||
config
|
||||
) &&
|
||||
periodStart.getTime() !== todayStart.getTime()
|
||||
) {
|
||||
return getEnergyFirstStatisticAt(now, locale, config);
|
||||
}
|
||||
// Next midnight in the configured zone, not browser-local addDays, so a
|
||||
// DST transition cannot skip a server-tz day.
|
||||
const nextMidnight = addMilliseconds(
|
||||
calcDate(now, endOfDay, locale, config),
|
||||
1
|
||||
);
|
||||
return midnightRollover ? nextMidnight : addHours(nextMidnight, 1);
|
||||
};
|
||||
|
||||
export const getEnergyDataCollection = (
|
||||
@@ -929,12 +1002,80 @@ export const getEnergyDataCollection = (
|
||||
}
|
||||
) as EnergyCollection;
|
||||
|
||||
collection._active = 0;
|
||||
collection.prefs = options.prefs;
|
||||
|
||||
// True while the collection is tracking the rolling "today" (or hour-0
|
||||
// yesterday) day. Cleared when the user picks a custom range.
|
||||
let followLiveDay = false;
|
||||
|
||||
const applyLiveDayPeriod = (now: Date): boolean => {
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
midnightRollover,
|
||||
now,
|
||||
hass.locale,
|
||||
hass.config,
|
||||
collection.start
|
||||
);
|
||||
const changed =
|
||||
collection.start.getTime() !== live.start.getTime() ||
|
||||
collection.end?.getTime() !== live.end.getTime();
|
||||
collection.start = live.start;
|
||||
collection.end = live.end;
|
||||
return changed;
|
||||
};
|
||||
|
||||
const clearUpdatePeriodTimeout = () => {
|
||||
if (collection._updatePeriodTimeout !== undefined) {
|
||||
window.clearTimeout(collection._updatePeriodTimeout);
|
||||
collection._updatePeriodTimeout = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleUpdatePeriod = () => {
|
||||
clearUpdatePeriodTimeout();
|
||||
const scheduledAt = new Date();
|
||||
collection._updatePeriodTimeout = window.setTimeout(
|
||||
() => {
|
||||
if (applyLiveDayPeriod(new Date())) {
|
||||
collection.refresh();
|
||||
}
|
||||
scheduleUpdatePeriod();
|
||||
},
|
||||
Math.max(
|
||||
0,
|
||||
getNextEnergyPeriodStart(
|
||||
midnightRollover,
|
||||
scheduledAt,
|
||||
hass.locale,
|
||||
hass.config,
|
||||
collection.start
|
||||
).getTime() - scheduledAt.getTime()
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const origSubscribe = collection.subscribe;
|
||||
|
||||
collection.subscribe = (subscriber: (data: EnergyData) => void) => {
|
||||
// Catch up before origSubscribe so the first fetch uses the live day.
|
||||
// Refresh only when state already exists: cold subscribe fetches via
|
||||
// origSubscribe; a re-subscribe inside the 5s unsub grace does not.
|
||||
const needsRefresh =
|
||||
followLiveDay &&
|
||||
applyLiveDayPeriod(new Date()) &&
|
||||
collection.state !== undefined;
|
||||
if (followLiveDay) {
|
||||
scheduleUpdatePeriod();
|
||||
}
|
||||
|
||||
const unsub = origSubscribe(subscriber);
|
||||
collection._active++;
|
||||
|
||||
if (needsRefresh) {
|
||||
collection.refresh();
|
||||
}
|
||||
|
||||
if (collection._refreshTimeout === undefined) {
|
||||
scheduleHourlyRefresh(collection);
|
||||
}
|
||||
@@ -942,79 +1083,55 @@ export const getEnergyDataCollection = (
|
||||
return () => {
|
||||
collection._active--;
|
||||
if (collection._active < 1) {
|
||||
clearTimeout(collection._refreshTimeout);
|
||||
collection._refreshTimeout = undefined;
|
||||
if (collection._refreshTimeout !== undefined) {
|
||||
window.clearTimeout(collection._refreshTimeout);
|
||||
collection._refreshTimeout = undefined;
|
||||
}
|
||||
clearUpdatePeriodTimeout();
|
||||
}
|
||||
unsub();
|
||||
};
|
||||
};
|
||||
|
||||
collection._active = 0;
|
||||
collection.prefs = options.prefs;
|
||||
|
||||
const now = new Date();
|
||||
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
|
||||
// Set start to start of today if we have data for today, otherwise yesterday.
|
||||
// The real-time "Now" view always tracks today; it shows live data even
|
||||
// before today's first statistic exists, so it never falls back to yesterday.
|
||||
const now = new Date();
|
||||
const preferredPeriod =
|
||||
(localStorage.getItem(
|
||||
getEnergyDefaultPeriodStorageKey(hass, options.key)
|
||||
) as DateRange) || "today";
|
||||
const period =
|
||||
preferredPeriod === "today" && hour === "0" && !midnightRollover
|
||||
preferredPeriod === "today" &&
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
midnightRollover,
|
||||
now,
|
||||
hass.locale,
|
||||
hass.config
|
||||
)
|
||||
? "yesterday"
|
||||
: preferredPeriod;
|
||||
|
||||
const [start, end] = calcDateRange(hass.locale, hass.config, period);
|
||||
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
|
||||
collection.end = calcDate(end, endOfDay, hass.locale, hass.config);
|
||||
|
||||
const scheduleUpdatePeriod = () => {
|
||||
collection._updatePeriodTimeout = window.setTimeout(
|
||||
() => {
|
||||
collection.start = calcDate(
|
||||
new Date(),
|
||||
startOfDay,
|
||||
hass.locale,
|
||||
hass.config
|
||||
);
|
||||
collection.end = calcDate(
|
||||
new Date(),
|
||||
endOfDay,
|
||||
hass.locale,
|
||||
hass.config
|
||||
);
|
||||
collection.refresh();
|
||||
scheduleUpdatePeriod();
|
||||
},
|
||||
getNextEnergyPeriodStart(
|
||||
midnightRollover,
|
||||
new Date(),
|
||||
hass.locale,
|
||||
hass.config
|
||||
).getTime() - Date.now()
|
||||
);
|
||||
};
|
||||
scheduleUpdatePeriod();
|
||||
followLiveDay = preferredPeriod === "today";
|
||||
|
||||
collection.isActive = () => !!collection._active;
|
||||
collection.clearPrefs = () => {
|
||||
collection.prefs = undefined;
|
||||
};
|
||||
collection.setPeriod = (newStart: Date, newEnd?: Date) => {
|
||||
if (collection._updatePeriodTimeout) {
|
||||
clearTimeout(collection._updatePeriodTimeout);
|
||||
collection._updatePeriodTimeout = undefined;
|
||||
}
|
||||
clearUpdatePeriodTimeout();
|
||||
collection.start = newStart;
|
||||
collection.end = newEnd;
|
||||
if (
|
||||
const periodNow = new Date();
|
||||
followLiveDay =
|
||||
collection.start.getTime() ===
|
||||
calcDate(new Date(), startOfDay, hass.locale, hass.config).getTime() &&
|
||||
calcDate(periodNow, startOfDay, hass.locale, hass.config).getTime() &&
|
||||
collection.end?.getTime() ===
|
||||
calcDate(new Date(), endOfDay, hass.locale, hass.config).getTime()
|
||||
) {
|
||||
calcDate(periodNow, endOfDay, hass.locale, hass.config).getTime();
|
||||
if (followLiveDay) {
|
||||
scheduleUpdatePeriod();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.deviceName",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
name: "search_labels.areaName",
|
||||
weight: 6,
|
||||
@@ -129,14 +133,20 @@ export const getEntities = (
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
@@ -146,7 +156,11 @@ export const getEntities = (
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -159,6 +173,7 @@ export const getEntities = (
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Selector } from "../selector";
|
||||
@@ -82,10 +83,7 @@ export const formatSelectorValue = (
|
||||
if (!stateObj) {
|
||||
return entityId;
|
||||
}
|
||||
const name = hass.formatEntityName(stateObj, [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
]);
|
||||
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
return name || entityId;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
@@ -74,7 +74,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
attributes,
|
||||
yamlData: stateYamlData,
|
||||
} = this._getDetailData(this._stateObj);
|
||||
const { floor, area, device } = getEntityContext(
|
||||
const { floor, area, device, parentDevice } = getEntityContext(
|
||||
this._stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
@@ -83,6 +83,13 @@ class HaMoreInfoDetails extends LitElement {
|
||||
);
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceNameDisplay(
|
||||
parentDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
const deviceName = device
|
||||
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
|
||||
: undefined;
|
||||
@@ -112,6 +119,13 @@ class HaMoreInfoDetails extends LitElement {
|
||||
href: `/config/areas/area/${area.area_id}`,
|
||||
});
|
||||
}
|
||||
if (parentDevice && parentDeviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.dialogs.more_info_control.parent_device",
|
||||
value: parentDeviceName,
|
||||
href: `/config/devices/device/${parentDevice.id}`,
|
||||
});
|
||||
}
|
||||
if (device && deviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.components.related-items.device",
|
||||
@@ -145,6 +159,7 @@ class HaMoreInfoDetails extends LitElement {
|
||||
context: {
|
||||
...(floorName ? { floor: floorName } : {}),
|
||||
...(areaName ? { area: areaName } : {}),
|
||||
...(parentDeviceName ? { parent_device: parentDeviceName } : {}),
|
||||
...(deviceName ? { device: deviceName } : {}),
|
||||
...(integrationName ? { integration: integrationName } : {}),
|
||||
},
|
||||
|
||||
@@ -615,11 +615,17 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
const defaultTitle = breadcrumb.pop() || entityId;
|
||||
const addToTitle = this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.title",
|
||||
|
||||
@@ -977,16 +977,26 @@ class DialogAddAutomationElement
|
||||
);
|
||||
} else {
|
||||
const stateObj = this.hass.states[targetId];
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
subtitle = [areaName, entityName ? deviceName : undefined]
|
||||
subtitle = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
|
||||
@@ -31,6 +31,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "id", weight: 2 },
|
||||
@@ -64,14 +65,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
@@ -89,6 +96,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
|
||||
@@ -160,11 +160,17 @@ export class DialogVacuumSegmentMapping
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
|
||||
@@ -195,7 +195,12 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const nameList = computeEntityNameList(
|
||||
entity,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
this._registries.areas,
|
||||
@@ -218,13 +223,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, deviceName, areaName] = nameList;
|
||||
const [, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
if (deviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parentDeviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (areaName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
@@ -237,14 +247,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
private _renderItem = (item: FilteredEntity) => {
|
||||
const { entity: entityState, nameList } = item;
|
||||
const [entityName, deviceName, areaName] = nameList;
|
||||
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
const primary = entityName || deviceName || entityState.entity_id;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const showEntityId = this._config?.userData?.showEntityIdPicker;
|
||||
|
||||
@@ -63,8 +63,8 @@ export class HuiEntityEditor extends LitElement {
|
||||
this.hass.formatEntityName(
|
||||
stateObj,
|
||||
useDeviceName
|
||||
? [{ type: "area" }]
|
||||
: [{ type: "area" }, { type: "device" }],
|
||||
? [{ type: "area" }, { type: "parent_device" }]
|
||||
: [{ type: "area" }, { type: "parent_device" }, { type: "device" }],
|
||||
{
|
||||
separator: isRTL ? " ◂ " : " ▸ ",
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ interface EntityPickerTableRowData extends DataTableRowData {
|
||||
name: string;
|
||||
entity_name?: string;
|
||||
device_name?: string;
|
||||
parent_device_name?: string;
|
||||
area_name?: string;
|
||||
domain_name: string;
|
||||
last_changed: string;
|
||||
@@ -60,14 +61,20 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
(entity) => {
|
||||
const stateObj = this.hass.states[entity];
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const name = [deviceName, entityName].filter(Boolean).join(" ");
|
||||
const domain = computeDomain(entity);
|
||||
|
||||
@@ -78,6 +85,7 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
name: name,
|
||||
entity_name: entityName,
|
||||
device_name: deviceName,
|
||||
parent_device_name: parentDeviceName,
|
||||
area_name: areaName,
|
||||
domain_name: domainToName(localize, domain),
|
||||
last_changed: stateObj!.last_changed,
|
||||
@@ -152,6 +160,7 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
entity.entity_name || entity.device_name || entity.entity_id;
|
||||
const secondary = [
|
||||
entity.area_name,
|
||||
entity.parent_device_name,
|
||||
entity.entity_name ? entity.device_name : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -191,6 +200,12 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
columns.parent_device_name = {
|
||||
title: "parent_device_name",
|
||||
filterable: true,
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
columns.area_name = {
|
||||
title: "area_name",
|
||||
filterable: true,
|
||||
|
||||
@@ -172,14 +172,20 @@ export class HuiHeadingBadgesEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -187,7 +193,11 @@ export class HuiHeadingBadgesEditor extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [entityName ? deviceName : undefined, areaName]
|
||||
const secondary = [
|
||||
entityName ? deviceName : undefined,
|
||||
parentDeviceName,
|
||||
areaName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { array, literal, object, string, union } from "superstruct";
|
||||
import { array, enums, literal, object, string, union } from "superstruct";
|
||||
import { ENTITY_NAME_TYPES } from "../../../../common/entity/compute_entity_name_display";
|
||||
|
||||
const entityNameItemStruct = union([
|
||||
object({
|
||||
@@ -6,12 +7,7 @@ const entityNameItemStruct = union([
|
||||
text: string(),
|
||||
}),
|
||||
object({
|
||||
type: union([
|
||||
literal("entity"),
|
||||
literal("device"),
|
||||
literal("area"),
|
||||
literal("floor"),
|
||||
]),
|
||||
type: enums(ENTITY_NAME_TYPES),
|
||||
}),
|
||||
string(),
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { join } from "lit/directives/join";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import {
|
||||
STRINGS_SEPARATOR_DOT,
|
||||
@@ -56,6 +57,13 @@ export const DEFAULT_STATE_CONTENT_DOMAINS: Record<string, StateContent> = {
|
||||
valve: ["state", "current_position"],
|
||||
};
|
||||
|
||||
const NAME_CONTENT_TYPES: Partial<Record<string, EntityNameType>> = {
|
||||
device_name: "device",
|
||||
parent_device_name: "parent_device",
|
||||
area_name: "area",
|
||||
floor_name: "floor",
|
||||
};
|
||||
|
||||
const TIMESTAMP_STATE_PROPS = ["last_updated", "last_changed"];
|
||||
|
||||
const TIMESTAMP_CONTENTS = [...TIMESTAMP_STATE_PROPS, "last_triggered"];
|
||||
@@ -190,13 +198,11 @@ class StateDisplay extends LitElement {
|
||||
if (content === "entity-id") {
|
||||
return stateObj.entity_id;
|
||||
}
|
||||
if (
|
||||
content === "device_name" ||
|
||||
content === "area_name" ||
|
||||
content === "floor_name"
|
||||
) {
|
||||
const type = content.replace("_name", "") as "device" | "area" | "floor";
|
||||
return this.hass.formatEntityName(stateObj, { type }) || undefined;
|
||||
const nameType = NAME_CONTENT_TYPES[content];
|
||||
if (nameType) {
|
||||
return (
|
||||
this.hass.formatEntityName(stateObj, { type: nameType }) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
let relativeDateTime: string | number | undefined;
|
||||
|
||||
@@ -764,10 +764,12 @@
|
||||
"types": {
|
||||
"floor": "Floor",
|
||||
"area": "Area",
|
||||
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
|
||||
"device": "Device",
|
||||
"entity": "Entity",
|
||||
"area_missing": "No area assigned",
|
||||
"floor_missing": "No floor assigned",
|
||||
"parent_device_missing": "No parent device",
|
||||
"device_missing": "No related device"
|
||||
},
|
||||
"mode_composed": "Composed",
|
||||
@@ -916,6 +918,7 @@
|
||||
"no_devices": "No devices available",
|
||||
"no_match": "No devices found for {term}",
|
||||
"device": "Device",
|
||||
"parent_device": "Parent device",
|
||||
"unnamed_device": "Unnamed device",
|
||||
"no_area": "No area",
|
||||
"placeholder": "Select a device",
|
||||
@@ -1446,6 +1449,7 @@
|
||||
"remaining_time": "Remaining time",
|
||||
"install_status": "Install status",
|
||||
"device_name": "Device name",
|
||||
"parent_device_name": "Parent device name",
|
||||
"area_name": "Area name",
|
||||
"floor_name": "Floor name"
|
||||
},
|
||||
@@ -1673,6 +1677,7 @@
|
||||
"context": "Context",
|
||||
"entity": "Entity",
|
||||
"floor": "Floor",
|
||||
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
|
||||
"entity_id": "Entity ID",
|
||||
"labels": "Labels",
|
||||
"toggle_yaml_mode": "Toggle YAML mode",
|
||||
|
||||
@@ -245,6 +245,43 @@ describe("computeEntityNameDisplay", () => {
|
||||
expect(result).toBe("Kitchen Smart Light");
|
||||
});
|
||||
|
||||
it("returns parent device name for an entity on a child device", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "switch.outlet_1" });
|
||||
const hass = {
|
||||
entities: {
|
||||
"switch.outlet_1": mockEntity({
|
||||
entity_id: "switch.outlet_1",
|
||||
name: "Switch",
|
||||
device_id: "child_1",
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
child_1: mockDevice({
|
||||
id: "child_1",
|
||||
name: "Outlet 1",
|
||||
parent_device_id: "parent_1",
|
||||
}),
|
||||
parent_1: mockDevice({
|
||||
id: "parent_1",
|
||||
name: "Power strip",
|
||||
}),
|
||||
},
|
||||
areas: {},
|
||||
floors: {},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const result = computeEntityNameDisplay(
|
||||
stateObj,
|
||||
[{ type: "parent_device" }, { type: "device" }, { type: "entity" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
expect(result).toBe("Power strip Outlet 1 Switch");
|
||||
});
|
||||
|
||||
it("returns floor name", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
|
||||
const hass = {
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
});
|
||||
@@ -88,6 +89,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -144,6 +146,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: childDevice,
|
||||
parentDevice,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -184,6 +187,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -223,8 +227,38 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not resolve a parent device for an entity on a main device", () => {
|
||||
const entity = mockEntity({
|
||||
entity_id: "switch.strip",
|
||||
device_id: "parent_1",
|
||||
});
|
||||
const parentDevice = mockDevice({
|
||||
id: "parent_1",
|
||||
});
|
||||
const stateObj = mockStateObj({
|
||||
entity_id: "switch.strip",
|
||||
});
|
||||
|
||||
const result = getEntityContext(
|
||||
stateObj,
|
||||
{ "switch.strip": entity },
|
||||
{ parent_1: parentDevice },
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: parentDevice,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMilliseconds,
|
||||
endOfDay,
|
||||
startOfDay,
|
||||
} from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { afterAll, assert, beforeAll, describe, it } from "vitest";
|
||||
|
||||
import { calcDate } from "../../src/common/datetime/calc_date";
|
||||
import {
|
||||
type FrontendLocaleData,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
FirstWeekday,
|
||||
DateFormat,
|
||||
TimeZone,
|
||||
} from "../../src/data/translation";
|
||||
import {
|
||||
getEnergyFirstStatisticAt,
|
||||
getEnergyLiveDayPeriod,
|
||||
getNextEnergyPeriodStart,
|
||||
} from "../../src/data/energy";
|
||||
|
||||
const locale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
};
|
||||
const tokyoConfig = { time_zone: "Asia/Tokyo" } as HassConfig;
|
||||
|
||||
// Dedicated file so Europe/Berlin can be pinned without leaking into other
|
||||
// suites. Vitest's default TZ is Etc/UTC, where browser-local addDays and
|
||||
// server-zone math often agree — so UTC CI would miss this regression.
|
||||
describe("energy period DST (Europe/Berlin local TZ)", () => {
|
||||
const originalTz = process.env.TZ;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "Europe/Berlin";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.TZ = originalTz;
|
||||
});
|
||||
|
||||
it("uses a DST-fallback browser zone for this file", () => {
|
||||
// 24 Oct 2026 is still CEST. If TZ pinning failed, offset is 0 (UTC).
|
||||
assert.equal(
|
||||
new Date("2026-10-24T14:30:00.000Z").getTimezoneOffset(),
|
||||
-120
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules tomorrow 01:00 in the server zone, not via browser-local addDays", () => {
|
||||
// 23:30 JST on 24 Oct 2026. Europe/Berlin falls back on 25 Oct; local
|
||||
// addDays(now, 1) then startOfDay in Tokyo skips to 26 Oct 01:00 JST.
|
||||
const now = new Date("2026-10-24T14:30:00.000Z");
|
||||
|
||||
// Compare to the tz-internal formula rather than a hardcoded instant:
|
||||
// the formula is the production invariant, and a pinned ISO string would
|
||||
// not explain why UTC CI cannot catch a raw addDays(now, 1) regression.
|
||||
const tzInternal = addHours(
|
||||
addMilliseconds(calcDate(now, endOfDay, locale, tokyoConfig), 1),
|
||||
1
|
||||
);
|
||||
const browserLocalAddDays = getEnergyFirstStatisticAt(
|
||||
addDays(now, 1),
|
||||
locale,
|
||||
tokyoConfig
|
||||
);
|
||||
const actual = getNextEnergyPeriodStart(false, now, locale, tokyoConfig);
|
||||
|
||||
assert.equal(actual.getTime(), tzInternal.getTime());
|
||||
assert.notEqual(actual.getTime(), browserLocalAddDays.getTime());
|
||||
assert.equal(
|
||||
actual.getTime(),
|
||||
new Date("2026-10-24T16:00:00.000Z").getTime()
|
||||
);
|
||||
assert.equal(
|
||||
browserLocalAddDays.getTime(),
|
||||
new Date("2026-10-25T16:00:00.000Z").getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves yesterday in the server zone, not via browser-local addDays", () => {
|
||||
// 00:30 JST on 26 Oct 2026. Browser-local addDays can land two days back.
|
||||
const now = new Date("2026-10-25T15:30:00.000Z");
|
||||
|
||||
const tzInternal = calcDate(
|
||||
calcDate(now, addDays, locale, tokyoConfig, -1),
|
||||
startOfDay,
|
||||
locale,
|
||||
tokyoConfig
|
||||
);
|
||||
const browserLocalAddDays = calcDate(
|
||||
addDays(now, -1),
|
||||
startOfDay,
|
||||
locale,
|
||||
tokyoConfig
|
||||
);
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
locale,
|
||||
tokyoConfig,
|
||||
new Date(0)
|
||||
);
|
||||
|
||||
assert.equal(live.start.getTime(), tzInternal.getTime());
|
||||
assert.notEqual(live.start.getTime(), browserLocalAddDays.getTime());
|
||||
});
|
||||
});
|
||||
+408
-19
@@ -1,6 +1,6 @@
|
||||
import { startOfDay } from "date-fns";
|
||||
import { addDays, endOfDay, startOfDay } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import { assert, describe, it } from "vitest";
|
||||
import { afterEach, assert, describe, it, vi } from "vitest";
|
||||
|
||||
import { calcDate } from "../../src/common/datetime/calc_date";
|
||||
import {
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
formatPowerShort,
|
||||
getNextEnergyPeriodStart,
|
||||
getEnergyDefaultPeriodStorageKey,
|
||||
getEnergyFirstStatisticAt,
|
||||
getEnergyLiveDayPeriod,
|
||||
shouldFallbackEnergyPeriodToYesterday,
|
||||
getEnergyDataCollection,
|
||||
EMPTY_PREFERENCES,
|
||||
} from "../../src/data/energy";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
|
||||
@@ -866,27 +871,55 @@ describe("Self-consumed solar gauge tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextEnergyPeriodStart", () => {
|
||||
const locale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
// Pin the time zone (via TimeZone.server) so energy period tests do not
|
||||
// depend on the machine's local zone.
|
||||
const energyPeriodLocale: FrontendLocaleData = {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.server,
|
||||
first_weekday: FirstWeekday.language,
|
||||
};
|
||||
const energyPeriodConfig = { time_zone: "America/New_York" } as HassConfig;
|
||||
const energyPeriodDay = (now: Date, offset = 0) => {
|
||||
const day = calcDate(
|
||||
now,
|
||||
addDays,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
offset
|
||||
);
|
||||
return {
|
||||
start: calcDate(day, startOfDay, energyPeriodLocale, energyPeriodConfig),
|
||||
end: calcDate(day, endOfDay, energyPeriodLocale, energyPeriodConfig),
|
||||
};
|
||||
// Pin the time zone (via TimeZone.server) so the test does not depend on the
|
||||
// machine's local zone.
|
||||
const config = { time_zone: "America/New_York" } as HassConfig;
|
||||
};
|
||||
|
||||
describe("getNextEnergyPeriodStart", () => {
|
||||
const isMidnight = (date: Date) =>
|
||||
calcDate(date, startOfDay, locale, config).getTime() === date.getTime();
|
||||
calcDate(
|
||||
date,
|
||||
startOfDay,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime() === date.getTime();
|
||||
|
||||
it("rolls the real-time view over at midnight, statistics an hour later", () => {
|
||||
const now = new Date("2026-06-19T15:30:00-04:00");
|
||||
|
||||
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
|
||||
const statistics = getNextEnergyPeriodStart(false, now, locale, config);
|
||||
const realTime = getNextEnergyPeriodStart(
|
||||
true,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
const statistics = getNextEnergyPeriodStart(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
|
||||
// Real-time rolls over exactly at the next midnight.
|
||||
assert.isTrue(isMidnight(realTime));
|
||||
@@ -896,9 +929,14 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
);
|
||||
|
||||
// Statistics roll over an hour after midnight, on the same day boundary.
|
||||
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000 - 1);
|
||||
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000);
|
||||
assert.equal(
|
||||
calcDate(statistics, startOfDay, locale, config).getTime(),
|
||||
calcDate(
|
||||
statistics,
|
||||
startOfDay,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime(),
|
||||
realTime.getTime()
|
||||
);
|
||||
});
|
||||
@@ -906,7 +944,12 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
it("advances the real-time view to the next midnight when called after midnight", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
|
||||
const realTime = getNextEnergyPeriodStart(true, now, locale, config);
|
||||
const realTime = getNextEnergyPeriodStart(
|
||||
true,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
);
|
||||
|
||||
assert.isTrue(isMidnight(realTime));
|
||||
// Next midnight is June 21, not the already-passed June 20 midnight.
|
||||
@@ -915,6 +958,352 @@ describe("getNextEnergyPeriodStart", () => {
|
||||
new Date("2026-06-21T00:00:00-04:00").getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("wakes a non-today live day at today 01:00 during hour 0", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
const todayOne = new Date("2026-06-20T01:00:00-04:00").getTime();
|
||||
|
||||
for (const offset of [-1, -2]) {
|
||||
assert.equal(
|
||||
getNextEnergyPeriodStart(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, offset).start
|
||||
).getTime(),
|
||||
todayOne
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps tomorrow 01:00 when statistics is already on today during hour 0", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
|
||||
assert.equal(
|
||||
getNextEnergyPeriodStart(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now).start
|
||||
).getTime(),
|
||||
new Date("2026-06-21T01:00:00-04:00").getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldFallbackEnergyPeriodToYesterday", () => {
|
||||
it("is true for the statistics view before 01:00", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
assert.isTrue(
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
)
|
||||
);
|
||||
assert.equal(
|
||||
getEnergyFirstStatisticAt(
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
).getTime(),
|
||||
new Date("2026-06-20T01:00:00-04:00").getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("is false at 01:00 and for the real-time view", () => {
|
||||
const atOne = new Date("2026-06-20T01:00:00-04:00");
|
||||
const beforeOne = new Date("2026-06-20T00:30:00-04:00");
|
||||
assert.isFalse(
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
false,
|
||||
atOne,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
)
|
||||
);
|
||||
assert.isFalse(
|
||||
shouldFallbackEnergyPeriodToYesterday(
|
||||
true,
|
||||
beforeOne,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnergyLiveDayPeriod", () => {
|
||||
it("keeps yesterday during hour 0 when that is the current period", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
const { start, end } = energyPeriodDay(now, -1);
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
start
|
||||
);
|
||||
assert.equal(live.start.getTime(), start.getTime());
|
||||
assert.equal(live.end.getTime(), end.getTime());
|
||||
});
|
||||
|
||||
it("keeps today during hour 0 when the user already picked today", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
const { start, end } = energyPeriodDay(now);
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
start
|
||||
);
|
||||
assert.equal(live.start.getTime(), start.getTime());
|
||||
assert.equal(live.end.getTime(), end.getTime());
|
||||
});
|
||||
|
||||
it("advances a stale yesterday to today after 01:00", () => {
|
||||
const now = new Date("2026-06-20T10:00:00-04:00");
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, -1).start
|
||||
);
|
||||
const expected = energyPeriodDay(now);
|
||||
assert.equal(live.start.getTime(), expected.start.getTime());
|
||||
assert.equal(live.end.getTime(), expected.end.getTime());
|
||||
});
|
||||
|
||||
it("advances a two-day-old live day to today", () => {
|
||||
const now = new Date("2026-06-20T10:00:00-04:00");
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, -2).start
|
||||
);
|
||||
const expected = energyPeriodDay(now);
|
||||
assert.equal(live.start.getTime(), expected.start.getTime());
|
||||
assert.equal(live.end.getTime(), expected.end.getTime());
|
||||
});
|
||||
|
||||
it("falls back to yesterday for a stale live day during hour 0", () => {
|
||||
const now = new Date("2026-06-20T00:30:00-04:00");
|
||||
const live = getEnergyLiveDayPeriod(
|
||||
false,
|
||||
now,
|
||||
energyPeriodLocale,
|
||||
energyPeriodConfig,
|
||||
energyPeriodDay(now, -2).start
|
||||
);
|
||||
const expected = energyPeriodDay(now, -1);
|
||||
assert.equal(live.start.getTime(), expected.start.getTime());
|
||||
assert.equal(live.end.getTime(), expected.end.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnergyDataCollection live day", () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const createCollection = (
|
||||
key: string,
|
||||
preset?: string,
|
||||
midnightRollover = false
|
||||
) => {
|
||||
const hass = createMockHass();
|
||||
hass.locale = energyPeriodLocale;
|
||||
hass.config = { ...hass.config, time_zone: "America/New_York" };
|
||||
const callWS = vi.fn(async (msg: { type: string }) => {
|
||||
if (msg.type === "energy/info") {
|
||||
return { cost_sensors: {}, solar_forecast_domains: [] };
|
||||
}
|
||||
throw new Error(`unexpected ${msg.type}`);
|
||||
});
|
||||
Object.assign(hass, {
|
||||
connection: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
connected: true,
|
||||
},
|
||||
callWS,
|
||||
});
|
||||
if (preset) {
|
||||
localStorage.setItem(getEnergyDefaultPeriodStorageKey(hass, key), preset);
|
||||
}
|
||||
return {
|
||||
collection: getEnergyDataCollection(hass, {
|
||||
key,
|
||||
prefs: EMPTY_PREFERENCES,
|
||||
midnightRollover,
|
||||
}),
|
||||
callWS,
|
||||
};
|
||||
};
|
||||
|
||||
const energyInfoFetches = (callWS: ReturnType<typeof vi.fn>) =>
|
||||
callWS.mock.calls.filter((call) => call[0].type === "energy/info");
|
||||
|
||||
it("advances hour-0 yesterday to today at 01:00 and fetches the new day", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_timer");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date(), -1).start.getTime()
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date()).start.getTime()
|
||||
);
|
||||
// Cards render EnergyData from the websocket store, not collection.start.
|
||||
// The 01:00 callback must refresh() so getEnergyData runs for today.
|
||||
assert.equal(refresh.mock.calls.length, 1);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("catches up a stale live day on resubscribe", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_catchup");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
unsub();
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date()).start.getTime()
|
||||
);
|
||||
assert.equal(refresh.mock.calls.length, 1);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not double-refresh on a cold subscribe after the unsub grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection, callWS } = createCollection("energy_cold_refresh");
|
||||
const refresh = vi.spyOn(collection, "refresh");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
unsub();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
refresh.mockClear();
|
||||
callWS.mockClear();
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
assert.equal(
|
||||
collection.start.getTime(),
|
||||
energyPeriodDay(new Date()).start.getTime()
|
||||
);
|
||||
// The library's first fetch is not collection.refresh(); this spy only
|
||||
// sees the extra refresh used during the unsub-grace re-subscribe.
|
||||
assert.equal(refresh.mock.calls.length, 0);
|
||||
assert.equal(energyInfoFetches(callWS).length, 1);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("keeps a custom setPeriod range overnight", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_custom_period");
|
||||
let unsub = collection.subscribe(() => undefined);
|
||||
const custom = energyPeriodDay(new Date(), -5);
|
||||
collection.setPeriod(custom.start, custom.end);
|
||||
unsub();
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
unsub = collection.subscribe(() => undefined);
|
||||
|
||||
assert.equal(collection.start.getTime(), custom.start.getTime());
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not advance the period after the last subscriber leaves", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_unsub_timer");
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
const start = collection.start.getTime();
|
||||
unsub();
|
||||
|
||||
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
|
||||
|
||||
assert.equal(collection.start.getTime(), start);
|
||||
});
|
||||
|
||||
it("does not roll a remembered week preset over to today", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection } = createCollection("energy_week_stored", "this_week");
|
||||
const weekStart = collection.start.getTime();
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
|
||||
|
||||
assert.equal(collection.start.getTime(), weekStart);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not roll a remembered week preset over to today with midnightRollover", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
|
||||
|
||||
const { collection } = createCollection(
|
||||
"energy_week_stored_now",
|
||||
"this_week",
|
||||
true
|
||||
);
|
||||
const weekStart = collection.start.getTime();
|
||||
const unsub = collection.subscribe(() => undefined);
|
||||
|
||||
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
|
||||
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
|
||||
|
||||
assert.equal(collection.start.getTime(), weekStart);
|
||||
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnergyDefaultPeriodStorageKey", () => {
|
||||
|
||||
Reference in New Issue
Block a user