diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index d796a80e64..bc7e1de564 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -2,67 +2,74 @@ import { HassEntity } from "home-assistant-js-websocket"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; import { FrontendLocaleData } from "../../data/translation"; import { - updateIsInstalling, - UpdateEntity, UPDATE_SUPPORT_PROGRESS, + updateIsInstallingFromAttributes, } from "../../data/update"; import { formatDate } from "../datetime/format_date"; import { formatDateTime } from "../datetime/format_date_time"; import { formatTime } from "../datetime/format_time"; -import { formatNumber, isNumericState } from "../number/format_number"; +import { formatNumber, isNumericFromAttributes } from "../number/format_number"; import { LocalizeFunc } from "../translations/localize"; -import { computeStateDomain } from "./compute_state_domain"; -import { supportsFeature } from "./supports-feature"; +import { supportsFeatureFromAttributes } from "./supports-feature"; import { formatDuration, UNIT_TO_SECOND_CONVERT } from "../datetime/duration"; +import { computeDomain } from "./compute_domain"; export const computeStateDisplay = ( localize: LocalizeFunc, stateObj: HassEntity, locale: FrontendLocaleData, state?: string -): string => { - const compareState = state !== undefined ? state : stateObj.state; +): string => + computeStateDisplayFromEntityAttributes( + localize, + locale, + stateObj.entity_id, + stateObj.attributes, + state !== undefined ? state : stateObj.state + ); - if (compareState === UNKNOWN || compareState === UNAVAILABLE) { - return localize(`state.default.${compareState}`); +export const computeStateDisplayFromEntityAttributes = ( + localize: LocalizeFunc, + locale: FrontendLocaleData, + entityId: string, + attributes: any, + state: string +): string => { + if (state === UNKNOWN || state === UNAVAILABLE) { + return localize(`state.default.${state}`); } // Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber` - if (isNumericState(stateObj)) { + if (isNumericFromAttributes(attributes)) { // state is duration if ( - stateObj.attributes.device_class === "duration" && - stateObj.attributes.unit_of_measurement && - UNIT_TO_SECOND_CONVERT[stateObj.attributes.unit_of_measurement] + attributes.device_class === "duration" && + attributes.unit_of_measurement && + UNIT_TO_SECOND_CONVERT[attributes.unit_of_measurement] ) { try { - return formatDuration( - compareState, - stateObj.attributes.unit_of_measurement - ); + return formatDuration(state, attributes.unit_of_measurement); } catch (_err) { // fallback to default } } - if (stateObj.attributes.device_class === "monetary") { + if (attributes.device_class === "monetary") { try { - return formatNumber(compareState, locale, { + return formatNumber(state, locale, { style: "currency", - currency: stateObj.attributes.unit_of_measurement, + currency: attributes.unit_of_measurement, minimumFractionDigits: 2, }); } catch (_err) { // fallback to default } } - return `${formatNumber(compareState, locale)}${ - stateObj.attributes.unit_of_measurement - ? " " + stateObj.attributes.unit_of_measurement - : "" + return `${formatNumber(state, locale)}${ + attributes.unit_of_measurement ? " " + attributes.unit_of_measurement : "" }`; } - const domain = computeStateDomain(stateObj); + const domain = computeDomain(entityId); if (domain === "input_datetime") { if (state !== undefined) { @@ -97,36 +104,32 @@ export const computeStateDisplay = ( } else { // If not trying to display an explicit state, create `Date` object from `stateObj`'s attributes then format. let date: Date; - if (stateObj.attributes.has_date && stateObj.attributes.has_time) { + if (attributes.has_date && attributes.has_time) { date = new Date( - stateObj.attributes.year, - stateObj.attributes.month - 1, - stateObj.attributes.day, - stateObj.attributes.hour, - stateObj.attributes.minute + attributes.year, + attributes.month - 1, + attributes.day, + attributes.hour, + attributes.minute ); return formatDateTime(date, locale); } - if (stateObj.attributes.has_date) { - date = new Date( - stateObj.attributes.year, - stateObj.attributes.month - 1, - stateObj.attributes.day - ); + if (attributes.has_date) { + date = new Date(attributes.year, attributes.month - 1, attributes.day); return formatDate(date, locale); } - if (stateObj.attributes.has_time) { + if (attributes.has_time) { date = new Date(); - date.setHours(stateObj.attributes.hour, stateObj.attributes.minute); + date.setHours(attributes.hour, attributes.minute); return formatTime(date, locale); } - return stateObj.state; + return state; } } if (domain === "humidifier") { - if (compareState === "on" && stateObj.attributes.humidity) { - return `${stateObj.attributes.humidity} %`; + if (state === "on" && attributes.humidity) { + return `${attributes.humidity} %`; } } @@ -136,7 +139,7 @@ export const computeStateDisplay = ( domain === "number" || domain === "input_number" ) { - return formatNumber(compareState, locale); + return formatNumber(state, locale); } // state of button is a timestamp @@ -144,12 +147,12 @@ export const computeStateDisplay = ( domain === "button" || domain === "input_button" || domain === "scene" || - (domain === "sensor" && stateObj.attributes.device_class === "timestamp") + (domain === "sensor" && attributes.device_class === "timestamp") ) { try { - return formatDateTime(new Date(compareState), locale); + return formatDateTime(new Date(state), locale); } catch (_err) { - return compareState; + return state; } } @@ -160,30 +163,28 @@ export const computeStateDisplay = ( // When the latest version is skipped, show the latest version // When update is not available, show "Up-to-date" // When update is not available and there is no latest_version show "Unavailable" - return compareState === "on" - ? updateIsInstalling(stateObj as UpdateEntity) - ? supportsFeature(stateObj, UPDATE_SUPPORT_PROGRESS) + return state === "on" + ? updateIsInstallingFromAttributes(attributes) + ? supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) ? localize("ui.card.update.installing_with_progress", { - progress: stateObj.attributes.in_progress, + progress: attributes.in_progress, }) : localize("ui.card.update.installing") - : stateObj.attributes.latest_version - : stateObj.attributes.skipped_version === - stateObj.attributes.latest_version - ? stateObj.attributes.latest_version ?? - localize("state.default.unavailable") + : attributes.latest_version + : attributes.skipped_version === attributes.latest_version + ? attributes.latest_version ?? localize("state.default.unavailable") : localize("ui.card.update.up_to_date"); } return ( // Return device class translation - (stateObj.attributes.device_class && + (attributes.device_class && localize( - `component.${domain}.state.${stateObj.attributes.device_class}.${compareState}` + `component.${domain}.state.${attributes.device_class}.${state}` )) || // Return default translation - localize(`component.${domain}.state._.${compareState}`) || + localize(`component.${domain}.state._.${state}`) || // We don't know! Return the raw state. - compareState + state ); }; diff --git a/src/common/entity/compute_state_name.ts b/src/common/entity/compute_state_name.ts index 34892ae5a1..2311830a9c 100644 --- a/src/common/entity/compute_state_name.ts +++ b/src/common/entity/compute_state_name.ts @@ -1,7 +1,13 @@ import { HassEntity } from "home-assistant-js-websocket"; import { computeObjectId } from "./compute_object_id"; +export const computeStateNameFromEntityAttributes = ( + entityId: string, + attributes: { [key: string]: any } +): string => + attributes.friendly_name === undefined + ? computeObjectId(entityId).replace(/_/g, " ") + : attributes.friendly_name || ""; + export const computeStateName = (stateObj: HassEntity): string => - stateObj.attributes.friendly_name === undefined - ? computeObjectId(stateObj.entity_id).replace(/_/g, " ") - : stateObj.attributes.friendly_name || ""; + computeStateNameFromEntityAttributes(stateObj.entity_id, stateObj.attributes); diff --git a/src/common/entity/supports-feature.ts b/src/common/entity/supports-feature.ts index 1b1c79b518..18bf3cd891 100644 --- a/src/common/entity/supports-feature.ts +++ b/src/common/entity/supports-feature.ts @@ -3,6 +3,13 @@ import { HassEntity } from "home-assistant-js-websocket"; export const supportsFeature = ( stateObj: HassEntity, feature: number +): boolean => supportsFeatureFromAttributes(stateObj.attributes, feature); + +export const supportsFeatureFromAttributes = ( + attributes: { + [key: string]: any; + }, + feature: number ): boolean => // eslint-disable-next-line no-bitwise - (stateObj.attributes.supported_features! & feature) !== 0; + (attributes.supported_features! & feature) !== 0; diff --git a/src/common/number/format_number.ts b/src/common/number/format_number.ts index 7cecd1c514..9c4d9e24ce 100644 --- a/src/common/number/format_number.ts +++ b/src/common/number/format_number.ts @@ -7,8 +7,11 @@ import { round } from "./round"; * @param stateObj The entity state object */ export const isNumericState = (stateObj: HassEntity): boolean => - !!stateObj.attributes.unit_of_measurement || - !!stateObj.attributes.state_class; + isNumericFromAttributes(stateObj.attributes); + +export const isNumericFromAttributes = (attributes: { + [key: string]: any; +}): boolean => !!attributes.unit_of_measurement || !!attributes.state_class; export const numberFormatToLocale = ( localeOptions: FrontendLocaleData diff --git a/src/data/cached-history.ts b/src/data/cached-history.ts index a75f9fd2f0..595c30af7b 100644 --- a/src/data/cached-history.ts +++ b/src/data/cached-history.ts @@ -1,13 +1,13 @@ -import { HassEntity } from "home-assistant-js-websocket"; import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; import { computeHistory, - fetchRecent, + HistoryStates, HistoryResult, LineChartUnit, TimelineEntity, entityIdHistoryNeedsAttributes, + fetchRecentWS, } from "./history"; export interface CacheConfig { @@ -55,7 +55,7 @@ export const getRecent = ( } const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId); - const prom = fetchRecent( + const prom = fetchRecentWS( hass, entityId, startTime, @@ -134,12 +134,12 @@ export const getRecentWithCache = ( const noAttributes = !entityIdHistoryNeedsAttributes(hass, entityId); const genProm = async () => { - let fetchedHistory: HassEntity[][]; + let fetchedHistory: HistoryStates; try { const results = await Promise.all([ curCacheProm, - fetchRecent( + fetchRecentWS( hass, entityId, toFetchStartTime, diff --git a/src/data/history.ts b/src/data/history.ts index a631c3423a..3ac791c790 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -1,8 +1,7 @@ import { HassEntity } from "home-assistant-js-websocket"; import { computeDomain } from "../common/entity/compute_domain"; -import { computeStateDisplay } from "../common/entity/compute_state_display"; -import { computeStateDomain } from "../common/entity/compute_state_domain"; -import { computeStateName } from "../common/entity/compute_state_name"; +import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display"; +import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name"; import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; import { FrontendLocaleData } from "./translation"; @@ -27,7 +26,7 @@ const LINE_ATTRIBUTES_TO_KEEP = [ export interface LineChartState { state: string; - last_changed: string; + last_changed: number; attributes?: Record; } @@ -47,7 +46,7 @@ export interface LineChartUnit { export interface TimelineState { state_localize: string; state: string; - last_changed: string; + last_changed: number; } export interface TimelineEntity { @@ -141,6 +140,21 @@ export interface StatisticsValidationResults { [statisticId: string]: StatisticsValidationResult[]; } +export interface HistoryStates { + [entityId: string]: EntityHistoryState[]; +} + +interface EntityHistoryState { + /** state */ + s: string; + /** attributes */ + a: { [key: string]: any }; + /** last_changed; if set, also applies to lu */ + lc: number; + /** last_updated */ + lu: number; +} + export const entityIdHistoryNeedsAttributes = ( hass: HomeAssistant, entityId: string @@ -181,6 +195,27 @@ export const fetchRecent = ( return hass.callApi("GET", url); }; +export const fetchRecentWS = ( + hass: HomeAssistant, + entityId: string, + startTime: Date, + endTime: Date, + skipInitialState = false, + significantChangesOnly?: boolean, + minimalResponse = true, + noAttributes?: boolean +) => + hass.callWS({ + type: "history/history_during_period", + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + significant_changes_only: significantChangesOnly || false, + include_start_time_state: !skipInitialState, + minimal_response: minimalResponse, + no_attributes: noAttributes || false, + entity_ids: [entityId], + }); + export const fetchDate = ( hass: HomeAssistant, startTime: Date, @@ -198,6 +233,27 @@ export const fetchDate = ( }` ); +export const fetchDateWS = ( + hass: HomeAssistant, + startTime: Date, + endTime: Date, + entityId?: string +) => { + const params = { + type: "history/history_during_period", + start_time: startTime.toISOString(), + end_time: endTime.toISOString(), + minimal_response: true, + no_attributes: !!( + entityId && !entityIdHistoryNeedsAttributes(hass, entityId) + ), + }; + if (entityId) { + return hass.callWS({ ...params, entity_ids: [entityId] }); + } + return hass.callWS(params); +}; + const equalState = (obj1: LineChartState, obj2: LineChartState) => obj1.state === obj2.state && // Only compare attributes if both states have an attributes object. @@ -212,46 +268,47 @@ const equalState = (obj1: LineChartState, obj2: LineChartState) => const processTimelineEntity = ( localize: LocalizeFunc, language: FrontendLocaleData, - states: HassEntity[] + entityId: string, + states: EntityHistoryState[] ): TimelineEntity => { const data: TimelineState[] = []; - const last_element = states.length - 1; - + const last: EntityHistoryState = states[states.length - 1]; for (const state of states) { - if (data.length > 0 && state.state === data[data.length - 1].state) { + if (data.length > 0 && state.s === data[data.length - 1].state) { continue; } - - // Copy the data from the last element as its the newest - // and is only needed to localize the data - if (!state.entity_id) { - state.attributes = states[last_element].attributes; - state.entity_id = states[last_element].entity_id; - } - data.push({ - state_localize: computeStateDisplay(localize, state, language), - state: state.state, - last_changed: state.last_changed, + state_localize: computeStateDisplayFromEntityAttributes( + localize, + language, + entityId, + state.a || last.a, + state.s + ), + state: state.s, + // lc (last_changed) may be omitted if its the same + // as lu (last_updated). + last_changed: (state.lc ? state.lc : state.lu) * 1000, }); } return { - name: computeStateName(states[0]), - entity_id: states[0].entity_id, + name: computeStateNameFromEntityAttributes(entityId, states[0].a), + entity_id: entityId, data, }; }; const processLineChartEntities = ( unit, - entities: HassEntity[][] + entities: HistoryStates ): LineChartUnit => { const data: LineChartEntity[] = []; - for (const states of entities) { - const last: HassEntity = states[states.length - 1]; - const domain = computeStateDomain(last); + Object.keys(entities).forEach((entityId) => { + const states = entities[entityId]; + const last: EntityHistoryState = states[states.length - 1]; + const domain = computeDomain(entityId); const processedStates: LineChartState[] = []; for (const state of states) { @@ -259,18 +316,24 @@ const processLineChartEntities = ( if (DOMAINS_USE_LAST_UPDATED.includes(domain)) { processedState = { - state: state.state, - last_changed: state.last_updated, + state: state.s, + last_changed: state.lu * 1000, attributes: {}, }; for (const attr of LINE_ATTRIBUTES_TO_KEEP) { - if (attr in state.attributes) { - processedState.attributes![attr] = state.attributes[attr]; + if (attr in state.a) { + processedState.attributes![attr] = state.a[attr]; } } } else { - processedState = state; + processedState = { + state: state.s, + // lc (last_changed) may be omitted if its the same + // as lu (last_updated). + last_changed: (state.lc ? state.lc : state.lu) * 1000, + attributes: {}, + }; } if ( @@ -289,52 +352,53 @@ const processLineChartEntities = ( data.push({ domain, - name: computeStateName(last), - entity_id: last.entity_id, + name: computeStateNameFromEntityAttributes(entityId, last.a), + entity_id: entityId, states: processedStates, }); - } + }); return { unit, - identifier: entities.map((states) => states[0].entity_id).join(""), + identifier: Object.keys(entities).join(""), data, }; }; const stateUsesUnits = (state: HassEntity) => - "unit_of_measurement" in state.attributes || - "state_class" in state.attributes; + attributesHaveUnits(state.attributes); + +const attributesHaveUnits = (attributes: { [key: string]: any }) => + "unit_of_measurement" in attributes || "state_class" in attributes; export const computeHistory = ( hass: HomeAssistant, - stateHistory: HassEntity[][], + stateHistory: HistoryStates, localize: LocalizeFunc ): HistoryResult => { - const lineChartDevices: { [unit: string]: HassEntity[][] } = {}; + const lineChartDevices: { [unit: string]: HistoryStates } = {}; const timelineDevices: TimelineEntity[] = []; if (!stateHistory) { return { line: [], timeline: [] }; } - - stateHistory.forEach((stateInfo) => { + Object.keys(stateHistory).forEach((entityId) => { + const stateInfo = stateHistory[entityId]; if (stateInfo.length === 0) { return; } - const entityId = stateInfo[0].entity_id; const currentState = entityId in hass.states ? hass.states[entityId] : undefined; const stateWithUnitorStateClass = !currentState && - stateInfo.find((state) => state.attributes && stateUsesUnits(state)); + stateInfo.find((state) => state.a && attributesHaveUnits(state.a)); let unit: string | undefined; if (currentState && stateUsesUnits(currentState)) { unit = currentState.attributes.unit_of_measurement || " "; } else if (stateWithUnitorStateClass) { - unit = stateWithUnitorStateClass.attributes.unit_of_measurement || " "; + unit = stateWithUnitorStateClass.a.unit_of_measurement || " "; } else { unit = { climate: hass.config.unit_system.temperature, @@ -348,12 +412,15 @@ export const computeHistory = ( if (!unit) { timelineDevices.push( - processTimelineEntity(localize, hass.locale, stateInfo) + processTimelineEntity(localize, hass.locale, entityId, stateInfo) ); - } else if (unit in lineChartDevices) { - lineChartDevices[unit].push(stateInfo); + } else if (unit in lineChartDevices && entityId in lineChartDevices[unit]) { + lineChartDevices[unit][entityId].push(...stateInfo); } else { - lineChartDevices[unit] = [stateInfo]; + if (!(unit in lineChartDevices)) { + lineChartDevices[unit] = {}; + } + lineChartDevices[unit][entityId] = stateInfo; } }); diff --git a/src/data/update.ts b/src/data/update.ts index f888823d21..802c1d0554 100644 --- a/src/data/update.ts +++ b/src/data/update.ts @@ -7,7 +7,10 @@ import type { import { BINARY_STATE_ON } from "../common/const"; import { computeDomain } from "../common/entity/compute_domain"; import { computeStateDomain } from "../common/entity/compute_state_domain"; -import { supportsFeature } from "../common/entity/supports-feature"; +import { + supportsFeature, + supportsFeatureFromAttributes, +} from "../common/entity/supports-feature"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { HomeAssistant } from "../types"; @@ -35,8 +38,13 @@ export interface UpdateEntity extends HassEntityBase { } export const updateUsesProgress = (entity: UpdateEntity): boolean => - supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) && - typeof entity.attributes.in_progress === "number"; + updateUsesProgressFromAttributes(entity.attributes); + +export const updateUsesProgressFromAttributes = (attributes: { + [key: string]: any; +}): boolean => + supportsFeatureFromAttributes(attributes, UPDATE_SUPPORT_PROGRESS) && + typeof attributes.in_progress === "number"; export const updateCanInstall = ( entity: UpdateEntity, @@ -49,6 +57,11 @@ export const updateCanInstall = ( export const updateIsInstalling = (entity: UpdateEntity): boolean => updateUsesProgress(entity) || !!entity.attributes.in_progress; +export const updateIsInstallingFromAttributes = (attributes: { + [key: string]: any; +}): boolean => + updateUsesProgressFromAttributes(attributes) || !!attributes.in_progress; + export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) => hass.callWS({ type: "update/release_notes", diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index f47011d22a..c152af1c72 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -25,7 +25,7 @@ import "../../components/ha-date-range-picker"; import type { DateRangePickerRanges } from "../../components/ha-date-range-picker"; import "../../components/ha-icon-button"; import "../../components/ha-menu-button"; -import { computeHistory, fetchDate } from "../../data/history"; +import { computeHistory, fetchDateWS } from "../../data/history"; import "../../layouts/ha-app-layout"; import { haStyle } from "../../resources/styles"; import { HomeAssistant } from "../../types"; @@ -177,7 +177,7 @@ class HaPanelHistory extends LitElement { private async _getHistory() { this._isLoading = true; - const dateHistory = await fetchDate( + const dateHistory = await fetchDateWS( this.hass, this._startDate, this._endDate,