diff --git a/pyproject.toml b/pyproject.toml index bf64f96ba5..06f5f3e167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20221026.0" +version = "20221027.0" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md" diff --git a/src/common/datetime/first_weekday.ts b/src/common/datetime/first_weekday.ts index 48b7b75261..3c34eb7c14 100644 --- a/src/common/datetime/first_weekday.ts +++ b/src/common/datetime/first_weekday.ts @@ -11,16 +11,20 @@ export const weekdays = [ "saturday", ] as const; -export const firstWeekdayIndex = (locale: FrontendLocaleData): number => { +type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => { if (locale.first_weekday === FirstWeekday.language) { // @ts-ignore if ("weekInfo" in Intl.Locale.prototype) { // @ts-ignore return new Intl.Locale(locale.language).weekInfo.firstDay % 7; } - return getWeekStartByLocale(locale.language) % 7; + return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex; } - return weekdays.indexOf(locale.first_weekday); + return weekdays.includes(locale.first_weekday) + ? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex) + : 1; }; export const firstWeekday = (locale: FrontendLocaleData) => { diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index 8a597fd56a..b1f3be804d 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -253,7 +253,7 @@ class StatisticsChart extends LitElement { const firstStat = stats[0]; const meta = statisticsMetaData?.[firstStat.statistic_id]; let name = names[firstStat.statistic_id]; - if (!name) { + if (name === undefined) { name = getStatisticLabel(this.hass, firstStat.statistic_id, meta); } @@ -324,10 +324,14 @@ class StatisticsChart extends LitElement { const band = drawBands && (type === "min" || type === "max"); statTypes.push(type); statDataSets.push({ - label: `${name} (${this.hass.localize( - `ui.components.statistics_charts.statistic_types.${type}` - )}) - `, + label: name + ? `${name} (${this.hass.localize( + `ui.components.statistics_charts.statistic_types.${type}` + )}) + ` + : this.hass.localize( + `ui.components.statistics_charts.statistic_types.${type}` + ), fill: drawBands ? type === "min" ? "+1" diff --git a/src/components/ha-form/compute-initial-ha-form-data.ts b/src/components/ha-form/compute-initial-ha-form-data.ts index 96c1ae58fb..ebcec7414d 100644 --- a/src/components/ha-form/compute-initial-ha-form-data.ts +++ b/src/components/ha-form/compute-initial-ha-form-data.ts @@ -39,11 +39,11 @@ export const computeInitialHaFormData = ( const selector: Selector = field.selector; if ("device" in selector) { - data[field.name] = selector.device.multiple ? [] : ""; + data[field.name] = selector.device?.multiple ? [] : ""; } else if ("entity" in selector) { - data[field.name] = selector.entity.multiple ? [] : ""; + data[field.name] = selector.entity?.multiple ? [] : ""; } else if ("area" in selector) { - data[field.name] = selector.area.multiple ? [] : ""; + data[field.name] = selector.area?.multiple ? [] : ""; } else if ("boolean" in selector) { data[field.name] = false; } else if ( @@ -56,9 +56,9 @@ export const computeInitialHaFormData = ( ) { data[field.name] = ""; } else if ("number" in selector) { - data[field.name] = selector.number.min ?? 0; + data[field.name] = selector.number?.min ?? 0; } else if ("select" in selector) { - if (selector.select.options.length) { + if (selector.select?.options.length) { data[field.name] = selector.select.options[0][0]; } } else if ("duration" in selector) { @@ -75,7 +75,7 @@ export const computeInitialHaFormData = ( } else if ("color_rgb" in selector) { data[field.name] = [0, 0, 0]; } else if ("color_temp" in selector) { - data[field.name] = selector.color_temp.min_mireds ?? 153; + data[field.name] = selector.color_temp?.min_mireds ?? 153; } else if ( "action" in selector || "media" in selector || diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index c6f1be6efd..704a36ed2c 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -55,8 +55,8 @@ export class HaAreaSelector extends SubscribeMixin(LitElement) { protected updated(changedProperties: PropertyValues): void { if ( changedProperties.has("selector") && - (this.selector.area.device?.integration || - this.selector.area.entity?.integration) && + (this.selector.area?.device?.integration || + this.selector.area?.entity?.integration) && !this._entitySources ) { fetchEntitySourcesWithCache(this.hass).then((sources) => { @@ -67,14 +67,14 @@ export class HaAreaSelector extends SubscribeMixin(LitElement) { protected render(): TemplateResult { if ( - (this.selector.area.device?.integration || - this.selector.area.entity?.integration) && + (this.selector.area?.device?.integration || + this.selector.area?.entity?.integration) && !this._entitySources ) { return html``; } - if (!this.selector.area.multiple) { + if (!this.selector.area?.multiple) { return html` { - if (!this.selector.area.entity) { + if (!this.selector.area?.entity) { return true; } @@ -118,7 +118,7 @@ export class HaAreaSelector extends SubscribeMixin(LitElement) { }; private _filterDevices = (device: DeviceRegistryEntry): boolean => { - if (!this.selector.area.device) { + if (!this.selector.area?.device) { return true; } diff --git a/src/components/ha-selector/ha-selector-attribute.ts b/src/components/ha-selector/ha-selector-attribute.ts index da06508e2f..da194bdf22 100644 --- a/src/components/ha-selector/ha-selector-attribute.ts +++ b/src/components/ha-selector/ha-selector-attribute.ts @@ -30,9 +30,9 @@ export class HaSelectorAttribute extends SubscribeMixin(LitElement) { return html` `; } diff --git a/src/components/ha-selector/ha-selector-device.ts b/src/components/ha-selector/ha-selector-device.ts index bfb0928524..e56808b766 100644 --- a/src/components/ha-selector/ha-selector-device.ts +++ b/src/components/ha-selector/ha-selector-device.ts @@ -53,7 +53,7 @@ export class HaDeviceSelector extends SubscribeMixin(LitElement) { super.updated(changedProperties); if ( changedProperties.has("selector") && - this.selector.device.integration && + this.selector.device?.integration && !this._entitySources ) { fetchEntitySourcesWithCache(this.hass).then((sources) => { @@ -63,11 +63,11 @@ export class HaDeviceSelector extends SubscribeMixin(LitElement) { } protected render() { - if (this.selector.device.integration && !this._entitySources) { + if (this.selector.device?.integration && !this._entitySources) { return html``; } - if (!this.selector.device.multiple) { + if (!this.selector.device?.multiple) { return html` `; } diff --git a/src/components/ha-selector/ha-selector-entity.ts b/src/components/ha-selector/ha-selector-entity.ts index 2c7062171c..b56ecf1723 100644 --- a/src/components/ha-selector/ha-selector-entity.ts +++ b/src/components/ha-selector/ha-selector-entity.ts @@ -30,14 +30,14 @@ export class HaEntitySelector extends LitElement { @property({ type: Boolean }) public required = true; protected render() { - if (!this.selector.entity.multiple) { + if (!this.selector.entity?.multiple) { return html` { @@ -73,8 +73,16 @@ export class HaEntitySelector extends LitElement { } } - private _filterEntities = (entity: HassEntity): boolean => - filterSelectorEntities(this.selector.entity, entity, this._entitySources); + private _filterEntities = (entity: HassEntity): boolean => { + if (!this.selector?.entity) { + return true; + } + return filterSelectorEntities( + this.selector.entity, + entity, + this._entitySources + ); + }; } declare global { diff --git a/src/components/ha-selector/ha-selector-file.ts b/src/components/ha-selector/ha-selector-file.ts index 589e43cf9d..f46ca12696 100644 --- a/src/components/ha-selector/ha-selector-file.ts +++ b/src/components/ha-selector/ha-selector-file.ts @@ -32,7 +32,7 @@ export class HaFileSelector extends LitElement { return html` `; diff --git a/src/components/ha-selector/ha-selector-location.ts b/src/components/ha-selector/ha-selector-location.ts index 05a2b3b451..b4b0c6524b 100644 --- a/src/components/ha-selector/ha-selector-location.ts +++ b/src/components/ha-selector/ha-selector-location.ts @@ -43,7 +43,7 @@ export class HaLocationSelector extends LitElement { value?: LocationSelectorValue ): MarkerLocation[] => { const computedStyles = getComputedStyle(this); - const zoneRadiusColor = selector.location.radius + const zoneRadiusColor = selector.location?.radius ? computedStyles.getPropertyValue("--zone-radius-color") || computedStyles.getPropertyValue("--accent-color") : undefined; @@ -52,10 +52,10 @@ export class HaLocationSelector extends LitElement { id: "location", latitude: value?.latitude || this.hass.config.latitude, longitude: value?.longitude || this.hass.config.longitude, - radius: selector.location.radius ? value?.radius || 1000 : undefined, + radius: selector.location?.radius ? value?.radius || 1000 : undefined, radius_color: zoneRadiusColor, icon: - selector.location.icon || selector.location.radius + selector.location?.icon || selector.location?.radius ? "mdi:map-marker-radius" : "mdi:map-marker", location_editable: true, diff --git a/src/components/ha-selector/ha-selector-number.ts b/src/components/ha-selector/ha-selector-number.ts index 343490af31..4edf7e2eb2 100644 --- a/src/components/ha-selector/ha-selector-number.ts +++ b/src/components/ha-selector/ha-selector-number.ts @@ -27,7 +27,7 @@ export class HaNumberSelector extends LitElement { @property({ type: Boolean }) public disabled = false; protected render() { - const isBox = this.selector.number.mode === "box"; + const isBox = this.selector.number?.mode === "box"; return html`
@@ -37,10 +37,10 @@ export class HaNumberSelector extends LitElement { ? html`${this.label}${this.required ? " *" : ""}` : ""} @@ -80,7 +82,7 @@ export class HaNumberSelector extends LitElement { } private get _value() { - return this.value ?? (this.selector.number.min || 0); + return this.value ?? (this.selector.number?.min || 0); } private _handleInputChange(ev) { @@ -88,7 +90,7 @@ export class HaNumberSelector extends LitElement { const value = ev.target.value === "" || isNaN(ev.target.value) ? this.required - ? this.selector.number.min || 0 + ? this.selector.number?.min || 0 : undefined : Number(ev.target.value); if (this.value === value) { diff --git a/src/components/ha-selector/ha-selector-select.ts b/src/components/ha-selector/ha-selector-select.ts index c454d339cd..fddb300c7b 100644 --- a/src/components/ha-selector/ha-selector-select.ts +++ b/src/components/ha-selector/ha-selector-select.ts @@ -9,6 +9,7 @@ import type { HomeAssistant } from "../../types"; import "../ha-checkbox"; import "../ha-chip"; import "../ha-chip-set"; +import "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box"; import "../ha-formfield"; import "../ha-radio"; @@ -36,12 +37,13 @@ export class HaSelectSelector extends LitElement { private _filter = ""; protected render() { - const options = this.selector.select.options.map((option) => - typeof option === "object" ? option : { value: option, label: option } - ); + const options = + this.selector.select?.options.map((option) => + typeof option === "object" ? option : { value: option, label: option } + ) || []; - if (!this.selector.select.custom_value && this._mode === "list") { - if (!this.selector.select.multiple) { + if (!this.selector.select?.custom_value && this._mode === "list") { + if (!this.selector.select?.multiple) { return html`
${this.label} @@ -82,7 +84,7 @@ export class HaSelectSelector extends LitElement { `; } - if (this.selector.select.multiple) { + if (this.selector.select?.multiple) { const value = !this.value || this.value === "" ? [] : (this.value as string[]); @@ -123,7 +125,7 @@ export class HaSelectSelector extends LitElement { `; } - if (this.selector.select.custom_value) { + if (this.selector.select?.custom_value) { if ( this.value !== undefined && !options.find((option) => option.value === this.value) @@ -178,8 +180,8 @@ export class HaSelectSelector extends LitElement { private get _mode(): "list" | "dropdown" { return ( - this.selector.select.mode || - (this.selector.select.options.length < 6 ? "list" : "dropdown") + this.selector.select?.mode || + ((this.selector.select?.options?.length || 0) < 6 ? "list" : "dropdown") ); } @@ -243,7 +245,7 @@ export class HaSelectSelector extends LitElement { return; } - if (!this.selector.select.multiple) { + if (!this.selector.select?.multiple) { fireEvent(this, "value-changed", { value: newValue, }); @@ -271,14 +273,14 @@ export class HaSelectSelector extends LitElement { this._filter = ev?.detail.value || ""; const filteredItems = this.comboBox.items?.filter((item) => { - if (this.selector.select.multiple && this.value?.includes(item.value)) { + if (this.selector.select?.multiple && this.value?.includes(item.value)) { return false; } const label = item.label || item.value; return label.toLowerCase().includes(this._filter?.toLowerCase()); }); - if (this._filter && this.selector.select.custom_value) { + if (this._filter && this.selector.select?.custom_value) { filteredItems?.unshift({ label: this._filter, value: this._filter }); } diff --git a/src/components/ha-selector/ha-selector-state.ts b/src/components/ha-selector/ha-selector-state.ts index b72b0349bd..a28628ed86 100644 --- a/src/components/ha-selector/ha-selector-state.ts +++ b/src/components/ha-selector/ha-selector-state.ts @@ -30,9 +30,9 @@ export class HaSelectorState extends SubscribeMixin(LitElement) { return html` `; + } + + return html` + ${this.label ? html`` : ""} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-statistic": HaStatisticSelector; + } +} diff --git a/src/components/ha-selector/ha-selector-target.ts b/src/components/ha-selector/ha-selector-target.ts index 606e8d1702..55f078f6bf 100644 --- a/src/components/ha-selector/ha-selector-target.ts +++ b/src/components/ha-selector/ha-selector-target.ts @@ -64,8 +64,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { super.updated(changedProperties); if ( changedProperties.has("selector") && - (this.selector.target.device?.integration || - this.selector.target.entity?.integration) && + (this.selector.target?.device?.integration || + this.selector.target?.entity?.integration) && !this._entitySources ) { fetchEntitySourcesWithCache(this.hass).then((sources) => { @@ -76,8 +76,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { protected render(): TemplateResult { if ( - (this.selector.target.device?.integration || - this.selector.target.entity?.integration) && + (this.selector.target?.device?.integration || + this.selector.target?.entity?.integration) && !this._entitySources ) { return html``; @@ -94,7 +94,7 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { } private _filterEntities = (entity: HassEntity): boolean => { - if (!this.selector.target.entity) { + if (!this.selector.target?.entity) { return true; } @@ -106,7 +106,7 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { }; private _filterDevices = (device: DeviceRegistryEntry): boolean => { - if (!this.selector.target.device) { + if (!this.selector.target?.device) { return true; } diff --git a/src/components/ha-selector/ha-selector-text.ts b/src/components/ha-selector/ha-selector-text.ts index 003775832d..a9d3de47d4 100644 --- a/src/components/ha-selector/ha-selector-text.ts +++ b/src/components/ha-selector/ha-selector-text.ts @@ -39,7 +39,7 @@ export class HaTextSelector extends LitElement { .disabled=${this.disabled} @input=${this._handleChange} autocapitalize="none" - .autocomplete=${this.selector.text.autofill} + .autocomplete=${this.selector.text?.autocomplete} spellcheck="false" .required=${this.required} autogrow @@ -59,7 +59,7 @@ export class HaTextSelector extends LitElement { html`
` : this.selector.text?.suffix} .required=${this.required} - .autocomplete=${this.selector.text.autofill} + .autocomplete=${this.selector.text?.autocomplete} > ${this.selector.text?.type === "password" ? html` diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index e77a3e7847..09125e10a7 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -17,6 +17,7 @@ const LOAD_ELEMENTS = { device: () => import("./ha-selector-device"), duration: () => import("./ha-selector-duration"), entity: () => import("./ha-selector-entity"), + statistic: () => import("./ha-selector-statistic"), file: () => import("./ha-selector-file"), navigation: () => import("./ha-selector-navigation"), number: () => import("./ha-selector-number"), diff --git a/src/data/recorder.ts b/src/data/recorder.ts index e711cd4670..2d8115cdb3 100644 --- a/src/data/recorder.ts +++ b/src/data/recorder.ts @@ -1,4 +1,5 @@ import { computeStateName } from "../common/entity/compute_state_name"; +import { HaDurationData } from "../components/ha-duration-input"; import { HomeAssistant } from "../types"; export type StatisticType = "state" | "sum" | "min" | "max" | "mean"; @@ -19,6 +20,13 @@ export interface StatisticValue { state: number | null; } +export interface Statistic { + max: number | null; + mean: number | null; + min: number | null; + change: number | null; +} + export interface StatisticsMetaData { statistics_unit_of_measurement: string | null; statistic_id: string; @@ -122,6 +130,36 @@ export const fetchStatistics = ( units, }); +export const fetchStatistic = ( + hass: HomeAssistant, + statistic_id: string, + period: { + fixed_period?: { start: string | Date; end: string | Date }; + calendar?: { period: string; offset: number }; + rolling_window?: { duration: HaDurationData; offset: HaDurationData }; + }, + units?: StatisticsUnitConfiguration +) => + hass.callWS({ + type: "recorder/statistic_during_period", + statistic_id, + units, + fixed_period: period.fixed_period + ? { + start_time: + period.fixed_period.start instanceof Date + ? period.fixed_period.start.toISOString() + : period.fixed_period.start, + end_time: + period.fixed_period.end instanceof Date + ? period.fixed_period.end.toISOString() + : period.fixed_period.end, + } + : undefined, + calendar: period.calendar, + rolling_window: period.rolling_window, + }); + export const validateStatistics = (hass: HomeAssistant) => hass.callWS({ type: "recorder/validate_statistics", diff --git a/src/data/selector.ts b/src/data/selector.ts index 393ec28399..a50128c979 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -27,6 +27,7 @@ export type Selector = | ObjectSelector | SelectSelector | StateSelector + | StatisticSelector | StringSelector | TargetSelector | TemplateSelector @@ -36,26 +37,26 @@ export type Selector = export interface ActionSelector { // eslint-disable-next-line @typescript-eslint/ban-types - action: {}; + action: {} | null; } export interface AddonSelector { addon: { name?: string; slug?: string; - }; + } | null; } export interface SelectorDevice { - integration?: DeviceSelector["device"]["integration"]; - manufacturer?: DeviceSelector["device"]["manufacturer"]; - model?: DeviceSelector["device"]["model"]; + integration?: NonNullable["integration"]; + manufacturer?: NonNullable["manufacturer"]; + model?: NonNullable["model"]; } export interface SelectorEntity { - integration?: EntitySelector["entity"]["integration"]; - domain?: EntitySelector["entity"]["domain"]; - device_class?: EntitySelector["entity"]["device_class"]; + integration?: NonNullable["integration"]; + domain?: NonNullable["domain"]; + device_class?: NonNullable["device_class"]; } export interface AreaSelector { @@ -63,47 +64,47 @@ export interface AreaSelector { entity?: SelectorEntity; device?: SelectorDevice; multiple?: boolean; - }; + } | null; } export interface AttributeSelector { attribute: { entity_id?: string; hide_attributes?: readonly string[]; - }; + } | null; } export interface BooleanSelector { // eslint-disable-next-line @typescript-eslint/ban-types - boolean: {}; + boolean: {} | null; } export interface ColorRGBSelector { // eslint-disable-next-line @typescript-eslint/ban-types - color_rgb: {}; + color_rgb: {} | null; } export interface ColorTempSelector { color_temp: { min_mireds?: number; max_mireds?: number; - }; + } | null; } export interface ConfigEntrySelector { config_entry: { integration?: string; - }; + } | null; } export interface DateSelector { // eslint-disable-next-line @typescript-eslint/ban-types - date: {}; + date: {} | null; } export interface DateTimeSelector { // eslint-disable-next-line @typescript-eslint/ban-types - datetime: {}; + datetime: {} | null; } export interface DeviceSelector { @@ -113,13 +114,13 @@ export interface DeviceSelector { model?: string; entity?: SelectorEntity; multiple?: boolean; - }; + } | null; } export interface DurationSelector { duration: { enable_day?: boolean; - }; + } | null; } export interface EntitySelector { @@ -130,24 +131,31 @@ export interface EntitySelector { multiple?: boolean; include_entities?: string[]; exclude_entities?: string[]; + } | null; +} + +export interface StatisticSelector { + statistic: { + device_class?: string; + multiple?: boolean; }; } export interface FileSelector { file: { accept: string; - }; + } | null; } export interface IconSelector { icon: { placeholder?: string; fallbackPath?: string; - }; + } | null; } export interface LocationSelector { - location: { radius?: boolean; icon?: string }; + location: { radius?: boolean; icon?: string } | null; } export interface LocationSelectorValue { @@ -158,7 +166,7 @@ export interface LocationSelectorValue { export interface MediaSelector { // eslint-disable-next-line @typescript-eslint/ban-types - media: {}; + media: {} | null; } export interface MediaSelectorValue { @@ -176,7 +184,7 @@ export interface MediaSelectorValue { export interface NavigationSelector { // eslint-disable-next-line @typescript-eslint/ban-types - navigation: {}; + navigation: {} | null; } export interface NumberSelector { @@ -186,16 +194,16 @@ export interface NumberSelector { step?: number; mode?: "box" | "slider"; unit_of_measurement?: string; - }; + } | null; } export interface ObjectSelector { // eslint-disable-next-line @typescript-eslint/ban-types - object: {}; + object: {} | null; } export interface SelectOption { - value: string; + value: any; label: string; disabled?: boolean; } @@ -206,14 +214,14 @@ export interface SelectSelector { custom_value?: boolean; mode?: "list" | "dropdown"; options: readonly string[] | readonly SelectOption[]; - }; + } | null; } export interface StateSelector { state: { entity_id?: string; attribute?: string; - }; + } | null; } export interface StringSelector { @@ -234,35 +242,35 @@ export interface StringSelector { | "datetime-local" | "color"; suffix?: string; - autofill?: string; - }; + autocomplete?: string; + } | null; } export interface TargetSelector { target: { entity?: SelectorEntity; device?: SelectorDevice; - }; + } | null; } export interface TemplateSelector { // eslint-disable-next-line @typescript-eslint/ban-types - template: {}; + template: {} | null; } export interface ThemeSelector { // eslint-disable-next-line @typescript-eslint/ban-types - theme: {}; + theme: {} | null; } export interface TimeSelector { // eslint-disable-next-line @typescript-eslint/ban-types - time: {}; + time: {} | null; } export interface UiActionSelector { "ui-action": { actions?: UiAction[]; - }; + } | null; } export const filterSelectorDevices = ( diff --git a/src/dialogs/more-info/ha-more-info-history.ts b/src/dialogs/more-info/ha-more-info-history.ts index 43eaa9fd84..0177ca9eb9 100644 --- a/src/dialogs/more-info/ha-more-info-history.ts +++ b/src/dialogs/more-info/ha-more-info-history.ts @@ -36,6 +36,8 @@ export class MoreInfoHistory extends LitElement { private _showMoreHref = ""; + private _statNames?: Record; + private _throttleGetStateHistory = throttle(() => { this._getStateHistory(); }, 10000); @@ -62,6 +64,7 @@ export class MoreInfoHistory extends LitElement { .isLoadingData=${!this._statistics} .statisticsData=${this._statistics} .statTypes=${statTypes} + .names=${this._statNames} >` : html` { + await import("../editor/config-elements/hui-statistic-card-editor"); + return document.createElement("hui-statistic-card-editor"); + } + + public static getStubConfig( + hass: HomeAssistant, + entities: string[], + entitiesFill: string[] + ) { + const includeDomains = ["sensor"]; + const maxEntities = 1; + const foundEntities = findEntities( + hass, + maxEntities, + entities, + entitiesFill, + includeDomains, + (stateObj: HassEntity) => "state_class" in stateObj.attributes + ); + + return { + entity: foundEntities[0] || "", + period: { calendar: { period: "month", offset: 0 } }, + }; + } + + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StatisticCardConfig; + + @state() private _value?: number | null; + + @state() private _metadata?: StatisticsMetaData; + + @state() private _error?: string; + + private _interval?: number; + + private _footerElement?: HuiErrorCard | LovelaceHeaderFooter; + + public disconnectedCallback() { + super.disconnectedCallback(); + clearInterval(this._interval); + } + + public setConfig(config: StatisticCardConfig): void { + if (!config.entity) { + throw new Error("Entity must be specified"); + } + if (!config.stat_type) { + throw new Error("Statistic type must be specified"); + } + if (!config.period) { + throw new Error("Period must be specified"); + } + if ( + config.entity && + !isExternalStatistic(config.entity) && + !isValidEntityId(config.entity) + ) { + throw new Error("Invalid entity"); + } + + this._config = config; + this._error = undefined; + this._fetchStatistic(); + this._fetchMetadata(); + + if (this._config.footer) { + this._footerElement = createHeaderFooterElement(this._config.footer); + } else if (this._footerElement) { + this._footerElement = undefined; + } + } + + public async getCardSize(): Promise { + let size = 2; + if (this._footerElement) { + const footerSize = computeCardSize(this._footerElement); + size += footerSize instanceof Promise ? await footerSize : footerSize; + } + return size; + } + + protected render(): TemplateResult { + if (!this._config || !this.hass) { + return html``; + } + + if (this._error) { + return html` ${this._error} `; + } + + const stateObj = this.hass.states[this._config.entity]; + const name = + this._config.name || + getStatisticLabel(this.hass, this._config.entity, this._metadata); + + return html` + +
+
${name}
+
+ +
+
+
+ ${this._value === undefined + ? "" + : this._value === null + ? "?" + : formatNumber(this._value, this.hass.locale)} + ${this._config.unit || + getDisplayUnit( + this.hass, + this._config.entity, + this._metadata + )} +
+ ${this._footerElement} +
+ `; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + // Side Effect used to update footer hass while keeping optimizations + if (this._footerElement) { + this._footerElement.hass = this.hass; + } + if ( + changedProps.has("_value") || + changedProps.has("_metadata") || + changedProps.has("_error") + ) { + return true; + } + if (this._config) { + return hasConfigOrEntityChanged(this, changedProps); + } + return true; + } + + protected firstUpdated() { + this._fetchStatistic(); + this._fetchMetadata(); + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (!this._config || !this.hass) { + return; + } + + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + const oldConfig = changedProps.get("_config") as + | EntityCardConfig + | undefined; + + if ( + !oldHass || + !oldConfig || + oldHass.themes !== this.hass.themes || + oldConfig.theme !== this._config.theme + ) { + applyThemesOnElement(this, this.hass.themes, this._config!.theme); + } + } + + private async _fetchStatistic() { + if (!this.hass || !this._config) { + return; + } + clearInterval(this._interval); + this._interval = window.setInterval( + () => this._fetchStatistic(), + 5 * 1000 * 60 + ); + try { + const stats = await fetchStatistic( + this.hass, + this._config.entity, + this._config.period + ); + this._value = stats[this._config!.stat_type]; + this._error = undefined; + } catch (e: any) { + this._error = e.message; + } + } + + private async _fetchMetadata() { + if (!this.hass || !this._config) { + return; + } + try { + this._metadata = ( + await getStatisticMetadata(this.hass, [this._config.entity]) + )?.[0]; + } catch (e: any) { + this._error = e.message; + } + } + + private _handleClick(): void { + fireEvent(this, "hass-more-info", { entityId: this._config!.entity }); + } + + static get styles(): CSSResultGroup { + return [ + css` + ha-card { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + cursor: pointer; + outline: none; + } + + .header { + display: flex; + padding: 8px 16px 0; + justify-content: space-between; + } + + .name { + color: var(--secondary-text-color); + line-height: 40px; + font-weight: 500; + font-size: 16px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .icon { + color: var(--state-icon-color, #44739e); + line-height: 40px; + } + + .info { + padding: 0px 16px 16px; + margin-top: -4px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + line-height: 28px; + } + + .value { + font-size: 28px; + margin-right: 4px; + } + + .measurement { + font-size: 18px; + color: var(--secondary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-statistic-card": HuiStatisticCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 8d7fe21412..b697e7c48f 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -1,4 +1,4 @@ -import { StatisticType } from "../../../data/recorder"; +import { Statistic, StatisticType } from "../../../data/recorder"; import { ActionConfig, LovelaceCardConfig } from "../../../data/lovelace"; import { FullCalendarView, TranslationDict } from "../../../types"; import { Condition } from "../common/validate-condition"; @@ -10,6 +10,7 @@ import { LovelaceRowConfig, } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; +import { HaDurationData } from "../../../components/ha-duration-input"; export interface AlarmPanelCardConfig extends LovelaceCardConfig { entity: string; @@ -309,6 +310,18 @@ export interface StatisticsGraphCardConfig extends LovelaceCardConfig { chart_type?: "line" | "bar"; } +export interface StatisticCardConfig extends LovelaceCardConfig { + title?: string; + entities: Array; + period: { + fixed_period?: { start: string; end: string }; + calendar?: { period: string; offset: number }; + rolling_window?: { duration: HaDurationData; offset: HaDurationData }; + }; + stat_type: keyof Statistic; + theme?: string; +} + export interface PictureCardConfig extends LovelaceCardConfig { image?: string; tap_action?: ActionConfig; diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index 4a943b9ab8..b1fb786dba 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -36,6 +36,7 @@ import { EnergyData, getEnergyDataCollection } from "../../../data/energy"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { HomeAssistant, ToggleButton } from "../../../types"; import { computeRTLDirection } from "../../../common/util/compute_rtl"; +import { firstWeekdayIndex } from "../../../common/datetime/first_weekday"; @customElement("hui-energy-period-selector") export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { @@ -179,11 +180,13 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { ? today : this._startDate; + const weekStartsOn = firstWeekdayIndex(this.hass.locale); + this._setDate( this._period === "day" ? startOfDay(start) : this._period === "week" - ? startOfWeek(start, { weekStartsOn: 1 }) + ? startOfWeek(start, { weekStartsOn }) : this._period === "month" ? startOfMonth(start) : startOfYear(start) @@ -191,11 +194,13 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { } private _pickToday() { + const weekStartsOn = firstWeekdayIndex(this.hass.locale); + this._setDate( this._period === "day" ? startOfToday() : this._period === "week" - ? startOfWeek(new Date(), { weekStartsOn: 1 }) + ? startOfWeek(new Date(), { weekStartsOn }) : this._period === "month" ? startOfMonth(new Date()) : startOfYear(new Date()) @@ -227,11 +232,13 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { } private _setDate(startDate: Date) { + const weekStartsOn = firstWeekdayIndex(this.hass.locale); + const endDate = this._period === "day" ? endOfDay(startDate) : this._period === "week" - ? endOfWeek(startDate, { weekStartsOn: 1 }) + ? endOfWeek(startDate, { weekStartsOn }) : this._period === "month" ? endOfMonth(startDate) : endOfYear(startDate); diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 115de4157b..98390dc9bf 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -79,6 +79,7 @@ const LAZY_LOAD_TYPES = { "shopping-list": () => import("../cards/hui-shopping-list-card"), starting: () => import("../cards/hui-starting-card"), "statistics-graph": () => import("../cards/hui-statistics-graph-card"), + statistic: () => import("../cards/hui-statistic-card"), "vertical-stack": () => import("../cards/hui-vertical-stack-card"), }; diff --git a/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts new file mode 100644 index 0000000000..f37f1a2b17 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-statistic-card-editor.ts @@ -0,0 +1,266 @@ +import type { HassEntity } from "home-assistant-js-websocket/dist/types"; +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { any, assert, assign, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import { domainIcon } from "../../../../common/entity/domain_icon"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import { deepEqual } from "../../../../common/util/deep-equal"; +import "../../../../components/ha-form/ha-form"; +import type { SchemaUnion } from "../../../../components/ha-form/types"; +import { + getStatisticMetadata, + StatisticsMetaData, + statisticsMetaHasType, + StatisticType, +} from "../../../../data/recorder"; +import type { HomeAssistant } from "../../../../types"; +import type { StatisticCardConfig } from "../../cards/types"; +import { headerFooterConfigStructs } from "../../header-footer/structs"; +import type { LovelaceCardEditor } from "../../types"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; + +const cardConfigStruct = assign( + baseLovelaceCardConfig, + object({ + entity: optional(string()), + name: optional(string()), + icon: optional(string()), + unit: optional(string()), + stat_type: optional(string()), + period: optional(any()), + theme: optional(string()), + footer: optional(headerFooterConfigStructs), + }) +); + +const stat_types = ["mean", "min", "max", "change"] as const; + +const statTypeMap: Record = { + mean: "mean", + min: "min", + max: "max", + change: "sum", +}; + +const periods = { + today: { calendar: { period: "day" } }, + yesterday: { calendar: { period: "day", offset: -1 } }, + this_week: { calendar: { period: "week" } }, + last_week: { calendar: { period: "week", offset: -1 } }, + this_month: { calendar: { period: "month" } }, + last_month: { calendar: { period: "month", offset: -1 } }, + this_year: { calendar: { period: "year" } }, + last_year: { calendar: { period: "year", offset: -1 } }, +} as const; + +@customElement("hui-statistic-card-editor") +export class HuiStatisticCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: StatisticCardConfig; + + @state() private _metadata?: StatisticsMetaData; + + public setConfig(config: StatisticCardConfig): void { + assert(config, cardConfigStruct); + this._config = config; + this._fetchMetadata(); + } + + firstUpdated() { + this._fetchMetadata().then(() => { + if (!this._config?.stat_type && this._config?.entity) { + fireEvent(this, "config-changed", { + config: { + ...this._config, + stat_type: this._metadata?.has_sum ? "change" : "mean", + }, + }); + } + }); + } + + private _data = memoizeOne((config: StatisticCardConfig) => { + if (!config || !config.period) { + return config; + } + for (const period of Object.values(periods)) { + if (deepEqual(period, config.period)) { + return { ...config, period }; + } + } + return config; + }); + + private _schema = memoizeOne( + ( + entity: string, + icon: string, + periodVal: any, + entityState: HassEntity, + localize: LocalizeFunc, + metadata?: StatisticsMetaData + ) => + [ + { name: "entity", required: true, selector: { statistic: {} } }, + { + name: "stat_type", + required: true, + selector: { + select: { + multiple: false, + options: stat_types.map((stat_type) => ({ + value: stat_type, + label: localize( + `ui.panel.lovelace.editor.card.statistic.stat_type_labels.${stat_type}` + ), + disabled: + !metadata || + !statisticsMetaHasType(metadata, statTypeMap[stat_type]), + })), + }, + }, + }, + { + name: "period", + required: true, + selector: Object.values(periods).includes(periodVal) + ? { + select: { + multiple: false, + options: Object.entries(periods).map( + ([periodKey, period]) => ({ + value: period, + label: + localize( + `ui.panel.lovelace.editor.card.statistic.periods.${periodKey}` + ) || periodKey, + }) + ), + }, + } + : { object: {} }, + }, + { + type: "grid", + name: "", + schema: [ + { name: "name", selector: { text: {} } }, + { + name: "icon", + selector: { + icon: { + placeholder: icon || entityState?.attributes.icon, + fallbackPath: + !icon && !entityState?.attributes.icon && entityState + ? domainIcon(computeDomain(entity), entityState) + : undefined, + }, + }, + }, + { name: "unit", selector: { text: {} } }, + { name: "theme", selector: { theme: {} } }, + ], + }, + ] as const + ); + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + const entityState = this.hass.states[this._config.entity]; + + const data = this._data(this._config); + + const schema = this._schema( + this._config.entity, + this._config.icon, + data.period, + entityState, + this.hass.localize, + this._metadata + ); + + return html` + + `; + } + + private async _fetchMetadata() { + if (!this.hass || !this._config) { + return; + } + this._metadata = ( + await getStatisticMetadata(this.hass, [this._config.entity]) + )[0]; + } + + private async _valueChanged(ev: CustomEvent) { + const config = ev.detail.value as StatisticCardConfig; + Object.keys(config).forEach((k) => config[k] === "" && delete config[k]); + if ( + config.stat_type && + config.entity && + config.entity !== this._metadata?.statistic_id + ) { + const metadata = ( + await getStatisticMetadata(this.hass!, [config.entity]) + )?.[0]; + if (metadata && !metadata.has_sum && config.stat_type === "change") { + config.stat_type = "mean"; + } + if (metadata && !metadata.has_mean && config.stat_type !== "change") { + config.stat_type = "change"; + } + } + if (!config.stat_type && config.entity) { + const metadata = ( + await getStatisticMetadata(this.hass!, [config.entity]) + )?.[0]; + config.stat_type = metadata?.has_sum ? "change" : "mean"; + } + fireEvent(this, "config-changed", { config }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + if (schema.name === "period") { + return this.hass!.localize( + "ui.panel.lovelace.editor.card.statistic.period" + ); + } + + if (schema.name === "theme") { + return `${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.theme" + )} (${this.hass!.localize( + "ui.panel.lovelace.editor.card.config.optional" + )})`; + } + + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-statistic-card-editor": HuiStatisticCardEditor; + } +} diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts index 3f3addb715..9dbefa4506 100644 --- a/src/panels/lovelace/editor/lovelace-cards.ts +++ b/src/panels/lovelace/editor/lovelace-cards.ts @@ -37,6 +37,10 @@ export const coreCards: Card[] = [ type: "statistics-graph", showElement: false, }, + { + type: "statistic", + showElement: true, + }, { type: "humidifier", showElement: true, diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index 05e5187259..e574a842f1 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -1007,7 +1007,10 @@ class HUIRoot extends LitElement { color: var(--error-color); } #view { - min-height: calc(100vh - var(--header-height)); + min-height: calc( + 100vh - var(--header-height) - env(safe-area-inset-top) - + env(safe-area-inset-bottom) + ); /** * Since we only set min-height, if child nodes need percentage * heights they must use absolute positioning so we need relative @@ -1022,7 +1025,10 @@ class HUIRoot extends LitElement { * In edit mode we have the tab bar on a new line * */ .edit-mode #view { - min-height: calc(100vh - var(--header-height) - 48px); + min-height: calc( + 100vh - var(--header-height) - 48px - env(safe-area-inset-top) - + env(safe-area-inset-bottom) + ); } #view > * { /** diff --git a/src/panels/profile/ha-pick-first-weekday-row.ts b/src/panels/profile/ha-pick-first-weekday-row.ts index 08b09af9a9..c9142efb57 100644 --- a/src/panels/profile/ha-pick-first-weekday-row.ts +++ b/src/panels/profile/ha-pick-first-weekday-row.ts @@ -31,7 +31,12 @@ class FirstWeekdayRow extends LitElement { .value=${this.hass.locale.first_weekday} @selected=${this._handleFormatSelection} > - ${Object.values(FirstWeekday).map((day) => { + ${[ + FirstWeekday.language, + FirstWeekday.monday, + FirstWeekday.saturday, + FirstWeekday.sunday, + ].map((day) => { const value = this.hass.localize( `ui.panel.profile.first_weekday.values.${day}` ); diff --git a/src/translations/en.json b/src/translations/en.json index 5aa0c39ff4..5f85ffaa98 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4065,6 +4065,28 @@ "pick_statistic": "Add a statistic", "picked_statistic": "Statistic" }, + "statistic": { + "name": "Statistic", + "description": "The Statistic card allows you to display a statistical value of an entity of a certain period.", + "period": "Period", + "stat_types": "Show stat", + "stat_type_labels": { + "mean": "Mean", + "min": "Min", + "max": "Max", + "change": "Change" + }, + "periods": { + "today": "Today", + "yesterday": "Yesterday", + "this_week": "This week", + "last_week": "Last week", + "this_month": "This month", + "last_month": "Last month", + "this_year": "This year", + "last_year": "Last year" + } + }, "horizontal-stack": { "name": "Horizontal Stack", "description": "The Horizontal Stack card allows you to stack together multiple cards, so they always sit next to each other in the space of one column."