const LitElement = window.LitElement || Object.getPrototypeOf(customElements.get('home-assistant') || customElements.get('hui-view')); const { html, css } = LitElement.prototype; const locale = { da: { minPrice: 'Minimumspris i dag:', maxPrice: 'Maksimal pris i dag:', minPriceNextDay: 'Minimumspris i morgen:', maxPriceNextDay: 'Maksimal pris i morgen:', infoNoNextDay: 'Morgendagens data er endnu ikke tilgængelige', from: 'fra', to: 'til' }, de: { minPrice: 'Mindestpreis heute:', maxPrice: 'Maximaler preis heute:', minPriceNextDay: 'Mindestpreis morgen:', maxPriceNextDay: 'Maximaler preis morgen:', infoNoNextDay: 'Die Daten von morgen sind noch nicht verfügbar', from: 'von', to: 'bis' }, en: { minPrice: 'Lowest price today:', maxPrice: 'Highest price today:', minPriceNextDay: 'Lowest price tomorrow:', maxPriceNextDay: 'Highest price tomorrow:', infoNoNextDay: "Tomorrow's data is not yet available", from: 'from', to: 'to' }, es: { minPrice: 'Precio mínimo hoy:', maxPrice: 'Precio máximo hoy:', minPriceNextDay: 'Precio mínimo mañana:', maxPriceNextDay: 'Precio máximo mañana:', infoNoNextDay: 'Los datos de mañana no están disponibles aún', from: 'de', to: 'a' }, fr: { minPrice: "Prix minimum aujourd'hui:", maxPrice: "Prix maximum aujourd'hui:", minPriceNextDay: 'Prix minimum demain:', maxPriceNextDay: 'Prix maximum demain:', infoNoNextDay: 'Les données de demain ne sont pas encore disponibles', from: 'de', to: 'à' }, nl: { minPrice: 'Minimumspris i dag:', maxPrice: 'Maksimal pris i dag:', minPriceNextDay: 'Minimum prijs morgen:', maxPriceNextDay: 'Maximale prijs morgen:', infoNoNextDay: 'De gegevens van morgen zijn nog niet beschikbaar', from: 'fra', to: 'til' }, ru: { minPrice: 'Минимальная цена сегодня:', maxPrice: 'Максимальная цена сегодня:', minPriceNextDay: 'Минимальная цена завтра:', maxPriceNextDay: 'Максимальная цена завтра:', infoNoNextDay: 'Данные завтра еще не доступны', from: 'С', to: 'до' }, sv: { minPrice: 'Lägsta pris idag:', maxPrice: 'Maxpris idag:', minPriceNextDay: 'Lägsta pris imorgon:', maxPriceNextDay: 'Maxpris i morgon:', infoNoNextDay: 'Morgondagens data är ännu inte tillgängliga', from: '', to: 'till' } }; const tariffPeriodIconColors = { error: '--error-color', normal: '--warning-color', peak: '--error-color', valley: '--success-color', 'super-valley': '--info-color' }; const tariffPeriodIcons = { error: 'M 28.342306,10.429944 27.798557,32.995546 H 24.243272 L 23.657695,10.429944 Z M 28.133172,41.570057 H 23.86683 v -4.412736 h 4.266342 z', normal: 'M 31.032172,16.612305 20.999855,32.113255 15.66609,25.065424 H 0.97821381 a 25.017275,25.017275 0 0 0 -0.0332829,0.949884 25.017275,25.017275 0 0 0 0.0468985,0.940092 H 14.800215 l 6.199595,8.453119 10.03232,-15.502917 5.335714,7.049798 h 14.578421 a 25.017275,25.017275 0 0 0 0.03328,-0.940092 25.017275,25.017275 0 0 0 -0.0469,-0.949884 H 37.233737 Z', peak: 'M 2.5238392,34.768609 A 25.003164,25.003164 0 0 1 1.9104804,32.879664 h 8.6436716 l 15.49805,-22.870055 15.121052,22.870055 h 8.891749 a 25.003164,25.003164 0 0 1 -0.628986,1.888945 H 40.038344 L 26.052202,13.679995 12.06606,34.768609 Z', valley: 'm 2.5238392,17.238401 a 25.003164,25.003164 0 0 0 -0.6133588,1.888945 h 8.6436716 l 15.49805,22.870055 15.121052,-22.870055 h 8.891749 A 25.003164,25.003164 0 0 0 49.436017,17.238401 H 40.038344 L 26.052202,38.327015 12.06606,17.238401 Z', 'super-valley': 'm 30.867213,27.342466 c 0,0.670334 -0.543413,1.213747 -1.213747,1.213746 -0.670333,-10e-7 -1.213744,-0.543413 -1.213744,-1.213746 0,-0.670333 0.543411,-1.213745 1.213744,-1.213746 0.670334,-1e-6 1.213747,0.543412 1.213747,1.213746 z m -7.282476,0 c 0,0.670333 -0.543412,1.213746 -1.213745,1.213746 -0.670334,0 -1.213746,-0.543412 -1.213746,-1.213746 0,-0.670334 0.543412,-1.213746 1.213746,-1.213746 0.670333,0 1.213745,0.543413 1.213745,1.213746 z m 8.026907,-6.869803 c -0.161832,-0.477407 -0.614966,-0.817256 -1.149013,-0.817256 h -8.900804 c -0.534048,0 -0.979088,0.339849 -1.149012,0.817256 l -1.683061,4.846893 v 6.473312 c 0,0.445039 0.364123,0.809164 0.809163,0.809164 h 0.809164 c 0.445041,0 0.809165,-0.364125 0.809165,-0.809164 v -0.809165 h 9.709967 v 0.809165 c 0,0.445039 0.364125,0.809164 0.809164,0.809164 h 0.809165 c 0.445039,0 0.809163,-0.364125 0.809163,-0.809164 v -6.473312 z m -9.800018,0.767664 h 8.393115 l 0.841531,2.49431 H 20.970096 Z m 9.89816,8.158458 H 20.314672 v -3.837522 l 0.0971,-0.275116 h 11.209006 l 0.089,0.275116 z M 25.208235,17.875001 v -1.607989 h -3.215979 l 4.823966,-2.411981 v 1.607988 H 30.0322 Z M 2.5904451,17.061236 C 2.3615878,17.681074 2.1574473,18.309759 1.9785073,18.945805 H 10.602006 V 37.331696 H 41.150085 V 18.945805 h 8.871408 c -0.184075,-0.636272 -0.393416,-1.26496 -0.62753,-1.884569 H 38.720725 V 35.001194 H 12.908652 V 17.061236 Z' }; const fireEvent = (node, type, detail, options) => { options = options || {}; detail = detail === null || detail === undefined ? {} : detail; const event = new Event(type, { bubbles: options.bubbles === undefined ? true : options.bubbles, cancelable: Boolean(options.cancelable), composed: options.composed === undefined ? true : options.composed }); event.detail = detail; node.dispatchEvent(event); return event; }; function hasConfigOrEntityChanged(element, changedProps) { if (changedProps.has('_config')) { return true; } const oldHass = changedProps.get('hass'); if (oldHass) { return oldHass.states[element._config.entity] !== element.hass.states[element._config.entity]; } return true; } class PVPCHourlyPricingCard extends LitElement { static get properties() { return { _config: { type: Object }, hass: { type: Object } }; } static async getConfigElement() { await import('./pvpc-hourly-pricing-card-editor.js'); return document.createElement('pvpc-hourly-pricing-card-editor'); } static getStubConfig(hass, entities, entitiesFallback) { const entity = Object.keys(hass.states).find((eid) => Object.keys(hass.states[eid].attributes).some((aid) => aid == 'min_price_at') ); return { entity: entity }; } setConfig(config) { if (!config.entity) { throw new Error('Please define a "Spain electricity hourly pricing (PVPC)" entity'); } this._config = config; this.setPVPCHourlyPricingObj(); } setPVPCHourlyPricingObj() { if (!this.hass) return; this.pvpcHourlyPricingObj = this._config.entity in this.hass.states ? this.hass.states[this._config.entity] : null; if (!this.pvpcHourlyPricingObj) return; this.despiction = this.getDespiction(this.pvpcHourlyPricingObj.attributes); } shouldUpdate(changedProps) { return hasConfigOrEntityChanged(this, changedProps); } updated(param) { this.setPVPCHourlyPricingObj(); let chart = this.shadowRoot.getElementById('Chart'); if (chart) { chart.data = this.ChartData; chart.hass = this.hass; } } render() { if (!this._config || !this.hass) { return html``; } this.setPVPCHourlyPricingObj(); this.numberElements = 0; this.lang = this.hass.selectedLanguage || this.hass.language; if (!this.pvpcHourlyPricingObj) { return html`
Entity not available: ${this._config.entity}
`; } return html` ${this._config.current !== false ? this.renderCurrent() : ''} ${this._config.details !== false ? this.renderDetails() : ''} ${this._config.graph !== false ? this.renderGraph() : ''} ${this._config.info !== false ? this.renderInfo() : ''} `; } renderCurrent() { this.numberElements++; const tariffPeriod = this.getTariffPeriod(this.pvpcHourlyPricingObj.attributes.tariff); const style = getComputedStyle(document.body); const iconColor = style.getPropertyValue(tariffPeriodIconColors[tariffPeriod]); return html`
${this.getFixedFloat(this.pvpcHourlyPricingObj.state)} ${this.pvpcHourlyPricingObj.attributes.unit_of_measurement}
`; } renderDetails() { if (!this.despiction) { return html``; } const minPrice = this.getFixedFloat(this.despiction.minPrice); const minPriceFrom = this.getTimeString(new Date().setHours(this.despiction.minIndex, 0)); const minPriceTo = this.getTimeString(new Date().setHours(this.despiction.minIndex + 1, 0)); const maxPrice = this.getFixedFloat(this.despiction.maxPrice); const maxPriceFrom = this.getTimeString(new Date().setHours(this.despiction.maxIndex, 0)); const maxPriceTo = this.getTimeString(new Date().setHours(this.despiction.maxIndex + 1, 0)); const minPriceNextDay = this.getFixedFloat(this.despiction.minPriceNextDay); const minPriceFromNextDay = this.getTimeString(new Date().setHours(this.despiction.minIndexNextDay, 0)); const minPriceToNextDay = this.getTimeString(new Date().setHours(this.despiction.minIndexNextDay + 1, 0)); const maxPriceNextDay = this.getFixedFloat(this.despiction.maxPriceNextDay); const maxPriceFromNextDay = this.getTimeString(new Date().setHours(this.despiction.maxIndexNextDay, 0)); const maxPriceToNextDay = this.getTimeString(new Date().setHours(this.despiction.maxIndexNextDay + 1, 0)); this.numberElements++; return html` `; } renderGraph() { if (!this.despiction) { return html``; } this.numberElements++; this.drawChart(); return html`
`; } renderInfo() { if (!this.despiction) { return html``; } this.numberElements++; if (!this.despiction.minPriceNextDay) { return html`
${this.ll('infoNoNextDay')}
`; } else { return html``; } } drawChart() { if (!this.despiction) return; const that = this; const style = getComputedStyle(document.body); const legendTextColor = style.getPropertyValue('--primary-text-color'); const axisTextColor = style.getPropertyValue('--secondary-text-color'); const dividerColor = style.getPropertyValue('--divider-color'); const selectionColor = style.getPropertyValue('--paper-grey-500'); const today = new Date(); const minIndex = this.despiction.minIndex; const maxIndex = this.despiction.maxIndex; const minIndexNextDay = this.despiction.minIndexNextDay; const maxIndexNextDay = this.despiction.maxIndexNextDay; const hasNextDayData = this.despiction.pricesNextDay[0] !== undefined; const minIcon = '▼'; const maxIcon = '▲'; const chartOptions = { type: 'line', data: { labels: this.despiction.dateTime, datasets: [ { label: that.getDateString(today), type: 'line', data: this.despiction.prices, borderWidth: 2.0, pointRadius: 0.0, pointHitRadius: 0.0, fill: false, steppedLine: true } ] }, options: { animation: { duration: 300, easing: 'linear', onComplete: function () { const chartInstance = this.chart; const ctx = chartInstance.ctx; const fontSize = 12; const fontStyle = 'normal'; const fontFamily = 'Roboto'; ctx.font = Chart.helpers.fontString(fontSize, fontStyle, fontFamily); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const meta = chartInstance.controller.getDatasetMeta(0); const minBarStart = meta.data[minIndex]; const minBarEnd = meta.data[minIndex + 1]; const pointToPointCenterXOffset = (minBarEnd._model.x - minBarStart._model.x) / 2; const maxBar = meta.data[maxIndex]; const iconYOffset = 8; ctx.fillStyle = meta.dataset._model.borderColor; ctx.fillText(minIcon, minBarStart._model.x + pointToPointCenterXOffset, minBarStart._model.y - iconYOffset); ctx.fillText(maxIcon, maxBar._model.x + pointToPointCenterXOffset, maxBar._model.y - iconYOffset); ctx.save(); const selectedIndex = chartInstance.tooltip._active && chartInstance.tooltip._active.length > 0 && chartInstance.tooltip._active[0]._index < 24 ? chartInstance.tooltip._active[0]._index : today.getHours(); const yaxis = meta.controller.chart.scales['y-axis-0']; const xBarStart = meta.data[selectedIndex]._model.x; const xBarEnd = meta.data[selectedIndex + 1]._model.x; const yBarStart = yaxis.top; const yBarEnd = yaxis.bottom; ctx.globalAlpha = 0.6; ctx.beginPath(); ctx.moveTo(xBarStart, yBarStart); ctx.lineTo(xBarStart, yBarEnd); ctx.strokeStyle = selectionColor; ctx.stroke(); ctx.beginPath(); ctx.moveTo(xBarEnd, yBarStart); ctx.lineTo(xBarEnd, yBarEnd); ctx.strokeStyle = selectionColor; ctx.stroke(); ctx.globalAlpha = 0.3; ctx.fillStyle = selectionColor; ctx.fillRect(xBarStart, yBarStart, xBarEnd - xBarStart, yBarEnd - yBarStart); ctx.restore(); if (hasNextDayData) { const meta_next_day = chartInstance.controller.getDatasetMeta(1); const minNextDayBar = meta_next_day.data[minIndexNextDay]; const maxNextDayBar = meta_next_day.data[maxIndexNextDay]; ctx.fillStyle = meta_next_day.dataset._model.borderColor; ctx.fillText( minIcon, minNextDayBar._model.x + pointToPointCenterXOffset, minNextDayBar._model.y - iconYOffset ); ctx.fillText( maxIcon, maxNextDayBar._model.x + pointToPointCenterXOffset, maxNextDayBar._model.y - iconYOffset ); } } }, legend: { display: true, labels: { fontColor: legendTextColor, fontSize: 14, usePointStyle: true, boxWidth: 6 } }, scales: { xAxes: [ { type: 'time', maxBarThickness: 15, display: false, ticks: { display: false }, gridLines: { display: false } }, { position: 'bottom', gridLines: { display: true, drawTicks: false, drawBorder: false, color: dividerColor }, ticks: { display: true, padding: 10, source: 'labels', autoSkip: true, fontColor: axisTextColor, maxRotation: 0, callback: function (value, index, values) { return that.getHourString.call(that, value); } } } ], yAxes: [ { position: 'left', gridLines: { display: true, drawBorder: false, drawTicks: false, color: dividerColor, borderDash: [4, 6] }, ticks: { display: true, padding: 10, fontColor: axisTextColor } } ] }, tooltips: { mode: 'index', intersect: false, callbacks: { title: function (items, data) { const index = items[0].index != 24 ? items[0].index : (items[0].index = 23); const date = new Date(data.labels[index]); const initDate = that.getTimeString(date); const endDate = that.getTimeString(date.setHours(date.getHours() + 1)); return initDate + ' - ' + endDate; }, label: function (tooltipItems, data) { let icon; const index = tooltipItems.index != 24 ? tooltipItems.index : (tooltipItems.index = 23); if (tooltipItems.datasetIndex === 0) { if (index == minIndex) { icon = minIcon; } else if (index == maxIndex) { icon = maxIcon; } } else if (tooltipItems.datasetIndex === 1) { if (index == minIndexNextDay) { icon = minIcon; } else if (index == maxIndexNextDay) { icon = maxIcon; } } const labelTitle = data.datasets[tooltipItems.datasetIndex].label || ''; const label = labelTitle + ': ' + parseFloat(tooltipItems.value).toFixed(5) + ' ' + that.pvpcHourlyPricingObj.attributes.unit_of_measurement + ' '; return icon ? label + icon : label; } } } } }; if (hasNextDayData) { chartOptions.data.datasets.push({ label: that.getDateString(today.setDate(today.getDate() + 1)), type: 'line', data: this.despiction.pricesNextDay, borderWidth: 2.0, pointRadius: 0.0, pointHitRadius: 0.0, fill: false, steppedLine: true }); } this.ChartData = chartOptions; } getDespiction(attributes) { const priceRegex = /price_\d\dh/; const priceNextDayRegex = /price_(next|last)_day_\d\dh/; const priceArray = Object.keys(attributes) .filter((key) => priceRegex.test(key)) .map((key) => attributes[key]); const priceNextDayArray = Object.keys(attributes) .filter((key) => priceNextDayRegex.test(key)) .map((key) => attributes[key]); let data = []; let dateTime = []; let prices = []; let pricesNextDay = []; for (let index = 0; index < 24; index++) { dateTime.push(new Date().setHours(index, 0)); prices.push(priceArray[index]); pricesNextDay.push(priceNextDayArray[index]); } dateTime.push(new Date().setHours(24, 0)); prices.push(priceArray[23]); pricesNextDay.push(priceNextDayArray[23]); data.dateTime = dateTime; data.prices = prices; data.pricesNextDay = pricesNextDay; data.minPrice = Math.min.apply(null, prices); data.maxPrice = Math.max.apply(null, prices); data.minIndex = prices.indexOf(data.minPrice); data.maxIndex = prices.indexOf(data.maxPrice); data.minPriceNextDay = Math.min.apply(null, pricesNextDay); data.maxPriceNextDay = Math.max.apply(null, pricesNextDay); data.minIndexNextDay = pricesNextDay.indexOf(data.minPriceNextDay); data.maxIndexNextDay = pricesNextDay.indexOf(data.maxPriceNextDay); return data; } getTariffPeriod(tariff) { let period; switch (tariff) { case 'normal': period = 'normal'; break; case 'discrimination': const utcHours = new Date().getUTCHours(); if (utcHours >= 21 || utcHours < 11) { period = 'valley'; } else { period = 'peak'; } break; case 'electric_car': const hours = new Date().getHours(); if (hours >= 1 && hours < 7) { period = 'super-valley'; } else if (hours >= 13 && hours < 23) { period = 'peak'; } else { period = 'valley'; } break; default: period = 'error'; } return period; } getDateString(datetime) { return new Date(datetime).toLocaleDateString(this.lang, { day: '2-digit', month: '2-digit', year: 'numeric' }); } getHourString(datetime) { return new Date(datetime).toLocaleTimeString(this.lang, { hour: '2-digit', hour12: false }); } getTimeString(datetime) { return new Date(datetime).toLocaleTimeString(this.lang, { hour: '2-digit', minute: '2-digit', hour12: false }); } getFixedFloat(number) { return parseFloat(number).toFixed(5); } _handleClick() { fireEvent(this, 'hass-more-info', { entityId: this._config.entity }); } getCardSize() { return this.numberElements || 3; } static get styles() { return css` ha-card { margin: auto; padding-top: 1.3em; padding-bottom: 1.3em; padding-left: 1em; padding-right: 1em; position: relative; } ha-icon { color: var(--paper-item-icon-color); } .spacer { padding-top: 1em; } .clear { clear: both; } .tappable { cursor: pointer; } .current { height: 5.5em; position: relative; display: flex; align-items: center; justify-content: space-between; } .period-icon { padding-left: 16px; padding-right: 16px; width: 5.5em; height: 5.5em; } .currentPrice { font-weight: 300; font-size: 4em; color: var(--primary-text-color); margin-top: 0.5em; margin-right: 8px; } .currentPriceUnit { font-weight: 300; font-size: 1.5em; vertical-align: super; color: var(--primary-text-color); right: 0em; top: 0em; position: absolute; margin-right: 8px; } .details { font-weight: 300; color: var(--primary-text-color); list-style: none; padding-right: 1em; padding-left: 1em; } .details li { display: flex; align-items: center; justify-content: flex-start; } .details ha-icon { height: 22px; margin-right: 4px; } .info { color: var(--primary-text-color); text-align: center; padding-right: 1em; padding-left: 1em; } `; } ll(str) { if (locale[this.lang] === undefined) return locale.en[str]; return locale[this.lang][str]; } } customElements.define('pvpc-hourly-pricing-card', PVPCHourlyPricingCard);