From 13307f8d818c2f7d5b5a9862bda59c8d29f536ae Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 27 Sep 2024 15:12:36 +0300 Subject: [PATCH] feat(#151): static relative start / end dates instead of using energy-date-selector By popular demand, we now have `time_period_from` & `time_period_to` options. Format is similar to Grafana. See the README --- README.md | 60 +++++++++++- rollup.config.js | 2 +- src/editor/index.ts | 16 +++- src/energy.ts | 21 ++-- src/ha-sankey-chart.ts | 169 ++++++++++++++++++++------------- src/localize/languages/en.json | 3 + src/section.ts | 2 +- src/types.ts | 2 + src/utils.ts | 46 ++++++++- 9 files changed, 235 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index e52b6ea..99ad5c5 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Install through [HACS](https://hacs.xyz/) | autoconfig | object | | Experimental. See [autoconfig](#autoconfig) | sections | list | | Required unless using autoconfig. Entities to show divided by sections, see [sections object](#sections-object) for additional options. | layout | string | auto | Valid options are: 'horizontal' - flow left to right, 'vertical' - flow top to bottom & 'auto' - determine based on available space (based on the section->`min_witdh` option, which defaults to 150) -| energy_date_selection | boolean | false | Integrate with the Energy Dashboard. Filters data based on the [energy-date-selection](https://www.home-assistant.io/dashboards/energy/) card. Use this only for accumulated data sensors (energy/water/gas) and with a `type:energy-date-selection` card. You still need to specify all your entities as HA doesn't know exactly how to connect them but you can use the general kWh entities that you have in the energy dashboard. In the future we may use areas to auto configure the chart. +| energy_date_selection | boolean | false | Integrate with the Energy Dashboard. Filters data based on the [energy-date-selection](https://www.home-assistant.io/dashboards/energy/) card. Use this only for accumulated data sensors (energy/water/gas) and with a `type:energy-date-selection` card. You still need to specify all your entities as HA doesn't know exactly how to connect them but you can use the general kWh entities that you have in the energy dashboard. In the future we may use areas to auto configure the chart. Not compatible with `time_period` | title | string | | Optional header title for the card | unit_prefix | string | | Metric prefix for the unit of measurment. See . Supported values are m, k, M, G, T | round | number | 0 | Round the value to at most N decimal places. May not apply to near zero values, see issue [#29](https://github.com/MindFreeze/ha-sankey-chart/issues/29) @@ -50,6 +50,8 @@ Install through [HACS](https://hacs.xyz/) | monetary_unit | string | | Currency of the gas or electricity price, e.g. 'USD' | sort_by | string | | Sort the entities. Valid options are: 'state'. If your values change often, you may want to use the `throttle` option to limit update frequency | sort_dir | string | desc | Sorting direction. Valid options are: 'asc' for smallest first & 'desc' for biggest first +| time_period_from | string | | Start of custom time period (e.g., "now-1d", "now/d"). Not compatible with `energy_date_selection`. See [Time period](#time-period) +| time_period_to | string | now | End of custom time period. Not compatible with `energy_date_selection`. See [Time period](#time-period) ### Sections object @@ -137,10 +139,57 @@ This card supports automatic configuration generation based on the HA energy das # any additional autoconfig options (listed below) ``` +or like this: + +```yaml +- type: custom:sankey-chart + autoconfig: true + time_period_from: "now/d" # today +``` + | Name | Type | Requirement | Default | Description | | ----------------- | ------- | ------------ | ------------------- | ------------------------------------------- | | print_yaml | boolean | **Optional** | false | Prints the auto generated configuration after the card so you can use it as a starting point for customization. It shows up like an error. Don't worry about it. +### Time Period + +The `time_period_from` and `time_period_to` options allow you to specify a custom time period for data retrieval. The format is based on [Grafana's time range format](https://grafana.com/docs/grafana/latest/dashboards/use-dashboards/?pg=blog&plcmt=body-txt#set-dashboard-time-range). + +Time units: s (seconds), m (minutes), h (hours), d (days), w (weeks), M (months), y (years) + +Note that while seconds and minutes are supported, there is a delay in the statistics data in HA of up to 1 hour, so showing small periods like the last 30 mins probably won't work. + +Examples: + +- `now-5m`: 5 minutes ago +- `now-1h`: 1 hour ago +- `now-1d`: 1 day ago +- `now-1w`: 1 week ago +- `now-1M`: 1 month ago +- `now/d`: Start of the current day +- `now/w`: Start of the current week +- `now/M`: Start of the current month +- `now/y`: Start of the current year +- `now-1d/d`: Start of the previous day + +If `time_period_to` is not specified, it defaults to `now`. + +Example configurations: + +```yaml +type: custom:sankey-chart +title: Last 7 days up to the current moment +time_period_from: "now-7d" +``` + +```yaml +type: custom:sankey-chart +title: Yesterday +time_period_from: "now-1d/d" +time_period_to: "now/d" +``` + + ## Examples ### Simple @@ -240,7 +289,7 @@ You can find more examples and help in the HA forum + hass.callWS({ + type: "energy/get_prefs", + }); + const fetchStatistics = ( hass: HomeAssistant, @@ -175,10 +180,10 @@ const calculateStatisticSumGrowth = ( return growth; }; -export async function getStatistics(hass: HomeAssistant, energyData: EnergyData, devices: string[], conversions: Conversions): Promise> { +export async function getStatistics(hass: HomeAssistant, { start, end }: Pick, devices: string[], conversions: Conversions): Promise> { const dayDifference = differenceInDays( - energyData.end || new Date(), - energyData.start + end || new Date(), + start ); const period = dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"; @@ -190,10 +195,10 @@ export async function getStatistics(hass: HomeAssistant, energyData: EnergyData, // If converting from kWh to CO2, we need to use a different API call to account for time-varying CO2 intensity time_variant_data[id] = fetchFossilEnergyConsumption( hass, - energyData.start, + start, [id], conversions.co2_intensity_entity, - energyData.end, + end, period ); } @@ -211,8 +216,8 @@ export async function getStatistics(hass: HomeAssistant, energyData: EnergyData, if (time_invariant_devices.length > 0) { time_invariant_data = await fetchStatistics( hass, - energyData.start, - energyData.end, + start, + end, time_invariant_devices, period, // units, diff --git a/src/ha-sankey-chart.ts b/src/ha-sankey-chart.ts index 9a6421e..0980552 100644 --- a/src/ha-sankey-chart.ts +++ b/src/ha-sankey-chart.ts @@ -18,11 +18,14 @@ import { getEnergyDataCollection, getEnergySourceColor, getStatistics, + getEnergyPreferences, + EnergyPreferences, } from './energy'; import { until } from 'lit/directives/until'; import { fetchFloorRegistry, getEntitiesByArea, HomeAssistantReal } from './hass'; import { LovelaceCardEditor } from 'custom-card-helpers'; import './editor/index'; +import { calculateTimePeriod } from './utils'; /* eslint no-console: 0 */ console.info( @@ -64,76 +67,110 @@ class SankeyChart extends SubscribeMixin(LitElement) { @state() private forceUpdateTs?: number; public hassSubscribe() { - if (!this.config.energy_date_selection) { - return []; - } - const start = Date.now(); - const getEnergyDataCollectionPoll = ( - resolve: (value: EnergyCollection | PromiseLike) => void, - reject: (reason?: any) => void, - ) => { - const energyCollection = getEnergyDataCollection(this.hass); - if (energyCollection) { - resolve(energyCollection); - } else if (Date.now() - start > ENERGY_DATA_TIMEOUT) { - console.debug(getEnergyDataCollection(this.hass)); - reject( - new Error('No energy data received. Make sure to add a `type: energy-date-selection` card to this screen.'), - ); - } else { - setTimeout(() => getEnergyDataCollectionPoll(resolve, reject), 100); - } - }; - const energyPromise = new Promise(getEnergyDataCollectionPoll); - setTimeout(() => { - if (!this.error && !Object.keys(this.states).length) { - this.error = new Error('Something went wrong. No energy data received.'); - console.debug(getEnergyDataCollection(this.hass)); - } - }, ENERGY_DATA_TIMEOUT * 2); - energyPromise.catch(err => { - this.error = err; - }); - return [ - energyPromise.then(async collection => { - const isAutoconfig = this.config.autoconfig || typeof this.config.autoconfig === 'object'; - if (isAutoconfig && !this.config.sections.length) { - try { - await this.autoconfig(collection); - } catch (err: any) { - this.error = new Error(err?.message || err); - } + const isAutoconfig = this.config.autoconfig || typeof this.config.autoconfig === 'object'; + if (this.config.energy_date_selection) { + const start = Date.now(); + const getEnergyDataCollectionPoll = ( + resolve: (value: EnergyCollection | PromiseLike) => void, + reject: (reason?: any) => void, + ) => { + const energyCollection = getEnergyDataCollection(this.hass); + if (energyCollection) { + resolve(energyCollection); + } else if (Date.now() - start > ENERGY_DATA_TIMEOUT) { + console.debug(getEnergyDataCollection(this.hass)); + reject( + new Error('No energy data received. Make sure to add a `type: energy-date-selection` card to this screen.'), + ); + } else { + setTimeout(() => getEnergyDataCollectionPoll(resolve, reject), 100); + } + }; + const energyPromise = new Promise(getEnergyDataCollectionPoll); + setTimeout(() => { + if (!this.error && !Object.keys(this.states).length) { + this.error = new Error('Something went wrong. No energy data received.'); + console.debug(getEnergyDataCollection(this.hass)); } - return collection.subscribe(async data => { + }, ENERGY_DATA_TIMEOUT * 2); + energyPromise.catch(err => { + this.error = err; + }); + return [ + energyPromise.then(async collection => { if (isAutoconfig && !this.config.sections.length) { try { - await this.autoconfig(collection); + await this.autoconfig(collection.prefs); } catch (err: any) { this.error = new Error(err?.message || err); - return; } } - if (this.entityIds.length) { - const conversions: Conversions = { - convert_units_to: this.config.convert_units_to!, - co2_intensity_entity: this.config.co2_intensity_entity!, - gas_co2_intensity: this.config.gas_co2_intensity!, - electricity_price: this.config.electricity_price, - gas_price: this.config.gas_price, - }; - const stats = await getStatistics(this.hass, data, this.entityIds, conversions); - const states: HassEntities = {}; - Object.keys(stats).forEach(id => { - if (this.hass.states[id]) { - states[id] = { ...this.hass.states[id], state: String(stats[id]) }; + return collection.subscribe(async data => { + if (isAutoconfig && !this.config.sections.length) { + try { + await this.autoconfig(collection.prefs); + } catch (err: any) { + this.error = new Error(err?.message || err); + return; } - }); - this.states = states; + } + if (this.entityIds.length) { + const conversions: Conversions = { + convert_units_to: this.config.convert_units_to!, + co2_intensity_entity: this.config.co2_intensity_entity!, + gas_co2_intensity: this.config.gas_co2_intensity!, + electricity_price: this.config.electricity_price, + gas_price: this.config.gas_price, + }; + const stats = await getStatistics(this.hass, data, this.entityIds, conversions); + const states: HassEntities = {}; + Object.keys(stats).forEach(id => { + if (this.hass.states[id]) { + states[id] = { ...this.hass.states[id], state: String(stats[id]) }; + } + }); + this.states = states; + } + this.forceUpdateTs = Date.now(); + }); + }), + ]; + } else if (this.config.time_period_from) { + const getTimePeriod = async () => { + if (isAutoconfig && !this.config.sections.length) { + await this.autoconfig(); + } + if (this.config.time_period_from) { + try { + const { start, end } = calculateTimePeriod(this.config.time_period_from, this.config.time_period_to); + if (this.entityIds.length) { + const conversions: Conversions = { + convert_units_to: this.config.convert_units_to!, + co2_intensity_entity: this.config.co2_intensity_entity!, + gas_co2_intensity: this.config.gas_co2_intensity!, + electricity_price: this.config.electricity_price, + gas_price: this.config.gas_price, + }; + const stats = await getStatistics(this.hass, { start, end }, this.entityIds, conversions); + const states: HassEntities = {}; + Object.keys(stats).forEach(id => { + if (this.hass.states[id]) { + states[id] = { ...this.hass.states[id], state: String(stats[id]) }; + } + }); + this.states = states; + } + } catch (err: any) { + this.error = err; } this.forceUpdateTs = Date.now(); - }); - }), - ]; + } + } + getTimePeriod(); + const interval = setInterval(getTimePeriod, this.config.throttle || 1000); + return [() => clearInterval(interval)]; + } + return []; } // https://lit.dev/docs/components/properties/#accessors-custom @@ -170,11 +207,11 @@ class SankeyChart extends SubscribeMixin(LitElement) { }); } - private async autoconfig(collection: EnergyCollection) { - if (!collection.prefs) { - return; + private async autoconfig(prefs?: EnergyPreferences) { + if (!prefs) { + prefs = await getEnergyPreferences(this.hass); } - const sources = (collection.prefs?.energy_sources || []) + const sources = (prefs?.energy_sources || []) .map(s => ({ ...s, ids: [s, ...(s.flow_from || [])] @@ -204,7 +241,7 @@ class SankeyChart extends SubscribeMixin(LitElement) { return 1; }); const names: Record = {}; - const deviceIds = (collection.prefs?.device_consumption || []) + const deviceIds = (prefs?.device_consumption || []) .filter(d => { if (!this.hass.states[d.stat_consumption]) { console.warn('Ignoring missing entity ' + d.stat_consumption); @@ -360,7 +397,7 @@ class SankeyChart extends SubscribeMixin(LitElement) { return html` ${renderBranchConnectors(props)} ` : null} - ${boxes.map((box, i) => { + ${boxes.map(box => { const { entity, extraSpacers } = box; const formattedState = formatState(box.state, props.config.round, props.locale, props.config.monetary_unit); const isNotPassthrough = box.config.type !== 'passthrough'; diff --git a/src/types.ts b/src/types.ts index c3aca27..7d042e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,8 @@ export interface SankeyChartConfig extends LovelaceCardConfig { static_scale?: number; sort_by?: 'none' | 'state'; sort_dir?: 'asc' | 'desc'; + time_period_from?: string; + time_period_to?: string; } declare global { diff --git a/src/utils.ts b/src/utils.ts index 06f81c7..2cb63a8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,6 +20,7 @@ import { Section, SectionConfig, } from './types'; +import { addSeconds, addMinutes, addHours, addDays, addWeeks, addMonths, addYears, startOfDay, startOfWeek, startOfMonth, startOfYear } from 'date-fns'; export function cloneObj>(obj: T): T { return JSON.parse(JSON.stringify(obj)); @@ -127,7 +128,7 @@ export function normalizeConfig(conf: SankeyChartConfig, isMetric: boolean): Con const { autoconfig } = conf; if (autoconfig || typeof autoconfig === 'object') { config = { - energy_date_selection: true, + energy_date_selection: !config.time_period_from, unit_prefix: 'k', round: 1, ...config, @@ -247,3 +248,46 @@ export async function renderError( return html` ${element} `; } + +export function calculateTimePeriod(from: string, to = 'now'): { start: Date; end: Date } { + const now = new Date(); + + function parseTimeString(timeStr: string): Date { + if (timeStr === 'now') return now; + + const match = timeStr.match(/^now(-|\+)?(\d+)?([smhdwMy])?(\/(d|w|M|y))?$/); + if (!match) throw new Error(`Invalid time format: ${timeStr}`); + + const [, sign, amount, unit, , roundTo] = match; + let date = new Date(now); + + if (amount && unit) { + const numAmount = parseInt(amount, 10) * (sign === '-' ? -1 : 1); + switch (unit) { + case 's': date = addSeconds(date, numAmount); break; + case 'm': date = addMinutes(date, numAmount); break; + case 'h': date = addHours(date, numAmount); break; + case 'd': date = addDays(date, numAmount); break; + case 'w': date = addWeeks(date, numAmount); break; + case 'M': date = addMonths(date, numAmount); break; + case 'y': date = addYears(date, numAmount); break; + } + } + + if (roundTo) { + switch (roundTo) { + case 'd': date = startOfDay(date); break; + case 'w': date = startOfWeek(date); break; + case 'M': date = startOfMonth(date); break; + case 'y': date = startOfYear(date); break; + } + } + + return date; + } + + const start = parseTimeString(from); + const end = parseTimeString(to); + + return { start, end }; +}