/**
* Pstryk Energy Card v4.1.0
*
* Przykładowe konfiguracje:
*
* FULL MODE:
* type: custom:pstryk-card
* buy_entity: sensor.pstryk_current_buy_price
* sell_entity: sensor.pstryk_current_sell_price
* card_mode: full # full | compact | super_compact
* title: Energy Prices
* show_title: true # true | false
* show_legend: true # true | false
* attribute_config: next_hour # next_hour | average_remaining | average_24 | custom_attribute_name | null
* hover_effect: lift # none | lift | glow | shake | pulse
* show_widget: sparkline # none | bars | sparkline
* widget_hours: 24 # liczba godzin do pokazania (1-48)
* widget_effect: pulse # none | pulse (dla sparkline) | fill (dla bars)
* alert_buy_above: 1.15 # próg alertu lub null
* alert_sell_below: 0.25 # próg alertu lub null
* click_action: none # none | more-info
* debug: false # true | false
*
* COMPACT MODE:
* type: custom:pstryk-card
* buy_entity: sensor.pstryk_current_buy_price
* sell_entity: sensor.pstryk_current_sell_price
* card_mode: compact
* title: Energy Prices
* show_title: true # true | false
* show_legend: false # domyślnie false dla compact
* attribute_config: average_24 # next_hour | average_remaining | average_24 | custom_attribute_name | null
* hover_effect: glow # none | lift | glow | shake | pulse
* show_widget: bars # none | bars | sparkline
* widget_hours: 12 # liczba godzin do pokazania (1-48)
* widget_effect: fill # none | pulse (dla sparkline) | fill (dla bars)
* alert_buy_above: null
* alert_sell_below: null
* click_action: more-info # none | more-info
* debug: false
*
* SUPER_COMPACT MODE:
* type: custom:pstryk-card
* buy_entity: sensor.pstryk_current_buy_price
* sell_entity: sensor.pstryk_current_sell_price
* card_mode: super_compact
* title: Energy Prices # ignorowane w super_compact
* show_title: false # zawsze false w super_compact
* show_legend: false # zawsze false w super_compact
* attribute_config: null # zawsze null w super_compact
* hover_effect: none # none | lift | glow | shake | pulse
* show_widget: none # zawsze none w super_compact
* widget_hours: 12 # ignorowane w super_compact
* widget_effect: none # ignorowane w super_compact
* alert_buy_above: 1.0
* alert_sell_below: 0.1
* click_action: none # none | more-info
* debug: false
*
* Changelog v4.1.0:
* - DODANO opcję widget_effect: fill dla trybu bars
* - Efekt wypełniania aktualnego słupka w trybie bars jest teraz opcjonalny
* - widget_effect: none - wyłącza efekt wypełniania (wszystkie słupki jednolite)
* - widget_effect: fill - włącza efekt wypełniania aktualnego słupka
*
* Changelog v4.0.12:
* - USUNIĘTO linię zero z widgetu bars (była niewidoczna na dole)
* - Zachowano linię zero w sparkline (bez zmian)
* - Zachowano prawidłową obsługę wartości ujemnych w bars
* - Wartości dodatnie: słupki w górę, ujemne: słupki w dół (od niewidocznej linii zero)
*/
class PstrykCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._config = {};
this._hass = null;
this._remainingHours = null;
this._translations = null;
this._avgPriceRegex = null;
this._refreshInterval = null;
}
connectedCallback() {
// Uruchom timer odświeżania co godzinę
this._startRefreshTimer();
}
disconnectedCallback() {
// Zatrzymaj timer przy usuwaniu karty
this._stopRefreshTimer();
}
_startRefreshTimer() {
this._stopRefreshTimer();
// Odświeżaj co godzinę jeśli widget jest włączony
if (this._config.show_widget !== 'none') {
// Oblicz czas do następnej pełnej godziny
const now = new Date();
const msToNextHour = (60 - now.getMinutes()) * 60 * 1000 - now.getSeconds() * 1000 - now.getMilliseconds();
// Pierwsze odświeżenie na początku następnej godziny
setTimeout(() => {
this.render();
// Potem odświeżaj co godzinę
this._refreshInterval = setInterval(() => {
this.render();
}, 3600000); // 60 minut
}, msToNextHour);
}
}
_stopRefreshTimer() {
if (this._refreshInterval) {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
}
setConfig(config) {
if (!config.buy_entity || !config.sell_entity) {
throw new Error('You must define buy_entity and sell_entity');
}
// Walidacja card_mode
const validModes = ['full', 'compact', 'super_compact'];
if (config.card_mode && !validModes.includes(config.card_mode)) {
throw new Error(`Invalid card_mode: ${config.card_mode}. Use: ${validModes.join(', ')}`);
}
// Walidacja show_widget
const validWidgets = ['none', 'bars', 'sparkline'];
if (config.show_widget && !validWidgets.includes(config.show_widget)) {
throw new Error(`Invalid show_widget: ${config.show_widget}. Use: ${validWidgets.join(', ')}`);
}
// Walidacja widget_effect
const validEffects = ['none', 'pulse', 'fill'];
if (config.widget_effect && !validEffects.includes(config.widget_effect)) {
throw new Error(`Invalid widget_effect: ${config.widget_effect}. Use: ${validEffects.join(', ')}`);
}
// Ustaw domyślny tryb jeśli nie podano
const cardMode = config.card_mode || 'full';
// Zastosuj domyślne wartości w zależności od trybu
this._config = this._applyModeDefaults({
...config,
card_mode: cardMode
});
}
_applyModeDefaults(config) {
const mode = config.card_mode || 'full';
// Domyślne wartości dla wszystkich trybów
const defaults = {
title: 'Energy Prices',
show_title: true,
show_legend: true,
attribute_config: 'next_hour',
hover_effect: 'lift',
show_widget: 'none',
widget_hours: 24,
widget_effect: 'none',
alert_buy_above: null,
alert_sell_below: null,
click_action: 'none',
debug: false
};
// Modyfikacje dla trybu compact
if (mode === 'compact') {
defaults.show_legend = false;
defaults.widget_hours = 12;
}
// Modyfikacje dla trybu super_compact
if (mode === 'super_compact') {
defaults.show_title = false;
defaults.show_legend = false;
defaults.attribute_config = null;
defaults.show_widget = 'none';
}
// Połącz z konfiguracją użytkownika (config ma priorytet)
return { ...defaults, ...config };
}
set hass(hass) {
const oldHass = this._hass;
this._hass = hass;
// Optymalizacja: renderuj tylko gdy zmienią się wartości encji
if (oldHass && this._config.buy_entity && this._config.sell_entity) {
const oldBuy = oldHass.states[this._config.buy_entity];
const oldSell = oldHass.states[this._config.sell_entity];
const newBuy = hass.states[this._config.buy_entity];
const newSell = hass.states[this._config.sell_entity];
if (oldBuy === newBuy && oldSell === newSell) {
return;
}
}
// Czyść cache tłumaczeń przy zmianie języka
if (oldHass && oldHass.language !== hass.language) {
this._translations = null;
}
this.render();
}
getCardSize() {
switch(this._config.card_mode) {
case 'super_compact':
return 1;
case 'compact':
return this._config.show_widget !== 'none' ? 2 : 1;
case 'full':
default:
return this._config.show_widget !== 'none' ? 3 : 2;
}
}
static getStubConfig() {
return {
buy_entity: 'sensor.pstryk_current_buy_price',
sell_entity: 'sensor.pstryk_current_sell_price',
card_mode: 'full',
show_widget: 'sparkline',
widget_effect: 'pulse'
};
}
_getTranslations() {
if (!this._translations) {
const isPolish = this._hass?.language === 'pl' ||
(this._hass?.locale?.language === 'pl') ||
(navigator.language?.startsWith('pl'));
this._translations = {
'buy_price': isPolish ? 'Cena Zakupu' : 'Buy Price',
'sell_price': isPolish ? 'Cena Sprzedaży' : 'Sell Price',
'best_prices': isPolish ? 'Najlepsze ceny' : 'Best prices',
'normal_prices': isPolish ? 'Normalne ceny' : 'Normal prices',
'worst_prices': isPolish ? 'Najgorsze ceny' : 'Worst prices',
'next_hour': isPolish ? 'Następna godzina' : 'Next hour',
'average_24': isPolish ? 'Średnia 24h' : 'Average 24h',
'alert': isPolish ? 'Uwaga!' : 'Alert!',
'high_price': isPolish ? 'Wysoka cena' : 'High price',
'low_price': isPolish ? 'Niska cena' : 'Low price',
'no_data': isPolish ? 'Brak danych' : 'No data',
'entity_not_found': isPolish ? 'Nie znaleziono encji' : 'Entity not found'
};
}
return this._translations;
}
translate(key) {
return this._getTranslations()[key] || key;
}
checkAlert(type, value) {
const numValue = parseFloat(value);
if (isNaN(numValue)) return false;
if (type === 'buy' && this._config.alert_buy_above) {
return numValue > this._config.alert_buy_above;
}
if (type === 'sell' && this._config.alert_sell_below) {
return numValue < this._config.alert_sell_below;
}
return false;
}
getPriceColor(entity, price, type) {
if (!entity?.attributes) return 'var(--primary-text-color)';
const currentPrice = parseFloat(price);
if (isNaN(currentPrice)) return 'var(--primary-text-color)';
// Pobierz tablice najlepszych i najgorszych cen
const bestPrices = entity.attributes['Najlepsze ceny'] ||
entity.attributes['Best prices'] ||
entity.attributes['best_prices'] || [];
const worstPrices = entity.attributes['Najgorsze ceny'] ||
entity.attributes['Worst prices'] ||
entity.attributes['worst_prices'] || [];
const epsilon = 0.001;
// Sprawdź w najlepszych
for (const priceData of bestPrices) {
const checkPrice = typeof priceData === 'object' ? priceData.price : priceData;
if (Math.abs(checkPrice - currentPrice) < epsilon) {
return '#4ade80'; // Zielony
}
}
// Sprawdź w najgorszych
for (const priceData of worstPrices) {
const checkPrice = typeof priceData === 'object' ? priceData.price : priceData;
if (Math.abs(checkPrice - currentPrice) < epsilon) {
return '#f87171'; // Czerwony
}
}
return 'var(--primary-text-color)';
}
formatPrice(price) {
if (price === null || price === undefined || price === '') return '--';
const numPrice = parseFloat(price);
if (isNaN(numPrice)) return '--';
return `${numPrice.toFixed(2)} PLN/kWh`;
}
getAttributeValue(entity, config) {
if (!entity?.attributes || !config) return null;
switch(config) {
case 'next_hour':
return entity.attributes['Next hour'];
case 'average_remaining':
if (!this._avgPriceRegex) {
this._avgPriceRegex = /Average price today \/(\d+)/;
}
for (const [key, value] of Object.entries(entity.attributes)) {
const match = key.match(this._avgPriceRegex);
if (match && match[1] !== '24') {
this._remainingHours = match[1];
return value;
}
}
return null;
case 'average_24':
return entity.attributes['Average price today /24'];
default:
return entity.attributes[config];
}
}
getAttributeLabel(config) {
switch(config) {
case 'next_hour':
return this.translate('next_hour');
case 'average_remaining':
const hours = this._remainingHours || 'X';
const translations = this._getTranslations();
const isPolish = translations['buy_price'] === 'Cena Zakupu';
return isPolish ? `Średnia ${hours}h` : `Average ${hours}h`;
case 'average_24':
return this.translate('average_24');
default:
return config;
}
}
generateWidgetData(entity, entityType) {
if (!entity?.attributes) return { data: [], currentIndex: 0 };
// Debug
if (this._config.debug) {
console.log(`[PstrykCard] ${entityType} generateWidgetData - attributes:`, entity.attributes);
}
// Szukaj historii cen w różnych możliwych atrybutach
let allPrices = entity.attributes['All prices'] ||
entity.attributes['all_prices'] ||
entity.attributes['prices'] ||
entity.attributes['hourly_prices'] ||
[];
if (this._config.debug) {
console.log(`[PstrykCard] ${entityType} raw prices data:`, allPrices);
console.log(`[PstrykCard] ${entityType} raw data sample:`, allPrices.slice(0, 10).map((item, idx) => `${idx}: ${JSON.stringify(item)}`));
// Pokaż problematyczne elementy dla sell
if (entityType === 'sell') {
console.log(`[PstrykCard] ${entityType} items 9-16:`, allPrices.slice(9, 17).map((item, idx) => `${idx+9}: ${JSON.stringify(item)}`));
}
}
if (!Array.isArray(allPrices) || allPrices.length === 0) {
if (this._config.debug) {
console.warn(`[PstrykCard] No price data found for ${entityType}`);
}
return { data: [], currentIndex: 0 };
}
const now = new Date();
const currentHour = now.getHours();
const currentMinutes = now.getMinutes();
const currentPrice = parseFloat(entity.state);
if (this._config.debug) {
console.log(`[PstrykCard] ${entityType} current: hour=${currentHour}, minutes=${currentMinutes}, price=${currentPrice}`);
}
// Konwertuj do tablicy z informacją o czasie - POPRAWIONE FILTROWANIE
const pricesWithTime = allPrices.map((item, index) => {
let price, hour, startTime, rawValue;
if (typeof item === 'object') {
rawValue = item.price || item.value || item.val || item;
// Sprawdź czy to jest zagnieżdżony obiekt z wartościami
if (typeof rawValue === 'object' && rawValue !== null) {
rawValue = rawValue.price || rawValue.value || rawValue.val || 0;
}
price = parseFloat(rawValue);
// Próbuj pobrać informację o czasie z różnych możliwych pól
if (item.start_time) {
startTime = new Date(item.start_time);
hour = startTime.getHours();
} else if (item.time) {
startTime = new Date(item.time);
hour = startTime.getHours();
} else if (item.hour !== undefined) {
hour = parseInt(item.hour);
} else {
// Jeśli nie ma informacji o czasie, zakładamy sekwencyjne godziny
hour = index % 24;
}
} else {
rawValue = item;
price = parseFloat(item);
hour = index % 24;
}
return { price, hour, index, originalIndex: index, rawValue };
}).filter(item => {
// LEPSZE FILTROWANIE: sprawdź czy wartość da się przekonwertować na liczbę
const isValid = !isNaN(item.price) && isFinite(item.price) && item.rawValue !== null && item.rawValue !== undefined && item.rawValue !== '';
if (this._config.debug && !isValid) {
console.log(`[PstrykCard] ${entityType} filtered out:`, JSON.stringify(item.rawValue), 'at index', item.originalIndex, 'parsed as:', item.price);
}
return isValid;
});
if (this._config.debug) {
console.log(`[PstrykCard] ${entityType} processed prices:`, pricesWithTime.map(p => `${p.hour}:00 = ${p.price}`));
}
// Sprawdź kolejność godzin w danych
if (this._config.debug && pricesWithTime.length > 1) {
const hourSequence = pricesWithTime.slice(0, 5).map(p => p.hour);
console.log(`[PstrykCard] ${entityType} first 5 hours sequence:`, hourSequence);
}
// Znajdź indeks aktualnej godziny
let currentIndex = -1;
// Metoda 1: Szukaj dokładnego dopasowania godziny
currentIndex = pricesWithTime.findIndex(item => item.hour === currentHour);
if (currentIndex !== -1 && this._config.debug) {
console.log(`[PstrykCard] ${entityType} found current hour ${currentHour} at index ${currentIndex}`);
}
// Metoda 2: Jeśli nie znaleziono, szukaj po cenie (z tolerancją)
if (currentIndex === -1) {
const epsilon = 0.01;
currentIndex = pricesWithTime.findIndex(item => Math.abs(item.price - currentPrice) < epsilon);
if (currentIndex !== -1 && this._config.debug) {
console.log(`[PstrykCard] ${entityType} found current by price match at index ${currentIndex}, hour: ${pricesWithTime[currentIndex].hour}`);
}
}
// Metoda 3: Jeśli nadal nie znaleziono i mamy pełne 24h danych
if (currentIndex === -1 && pricesWithTime.length >= 24) {
// Spróbuj znaleźć najbliższą godzinę
let minDiff = 24;
pricesWithTime.forEach((item, idx) => {
const diff = Math.abs(item.hour - currentHour);
if (diff < minDiff) {
minDiff = diff;
currentIndex = idx;
}
});
if (currentIndex !== -1 && this._config.debug) {
console.log(`[PstrykCard] ${entityType} using closest hour at index ${currentIndex}, hour: ${pricesWithTime[currentIndex].hour}`);
}
}
// Ostatnia deska ratunku - użyj środka
if (currentIndex === -1) {
currentIndex = Math.floor(pricesWithTime.length / 2);
if (this._config.debug) {
console.warn(`[PstrykCard] ${entityType} could not find current hour, using middle at index ${currentIndex}`);
}
}
// Pobierz dane tylko dla przyszłych godzin (od aktualnej)
const hoursToShow = Math.min(this._config.widget_hours || 24, pricesWithTime.length - currentIndex);
const futureData = pricesWithTime.slice(currentIndex, currentIndex + hoursToShow);
// Oblicz pozycję kropki (0-1) w aktualnej godzinie
const dotPosition = currentMinutes / 60;
if (this._config.debug) {
console.log(`[PstrykCard] ${entityType} showing hours:`, futureData.map(d => `${d.hour}:00 = ${d.price}`).join(', '));
}
return {
data: futureData.map(d => d.price),
hours: futureData.map(d => d.hour),
currentIndex: 0, // Zawsze 0, bo zaczynamy od aktualnej
dotPosition: dotPosition
};
}
createSparkline(data, hours, height, color, currentIndex, dotPosition, uniqueId) {
if (!data || data.length < 2) return '';
const min = Math.min(...data);
const max = Math.max(...data);
// Zawsze uwzględnij 0 w zakresie
const displayMin = Math.min(min, 0);
const displayMax = Math.max(max, 0);
const range = displayMax - displayMin || 1;
const viewBoxWidth = 200;
const viewBoxHeight = 50;
const paddingX = 5;
const paddingY = 5;
const chartWidth = viewBoxWidth - (paddingX * 2);
const chartHeight = viewBoxHeight - (paddingY * 2);
// Oblicz pozycję linii zero - zawsze będzie widoczna
const zeroY = paddingY + chartHeight - ((0 - displayMin) / range) * chartHeight;
// Generuj punkty dla linii
const points = data.map((value, index) => {
const x = paddingX + (index / (data.length - 1)) * chartWidth;
const y = paddingY + chartHeight - ((value - displayMin) / range) * chartHeight;
return `${x},${y}`;
}).join(' ');
let currentDot = '';
let pulseEffect = '';
// Kropka na aktualnej pozycji
if (currentIndex === 0 && data.length > 0) {
// Pozycja X między pierwszym a drugim punktem
const x1 = paddingX;
const x2 = data.length > 1 ? paddingX + (1 / (data.length - 1)) * chartWidth : x1;
const currentX = x1 + (x2 - x1) * dotPosition;
// Pozycja Y - interpolacja między wartościami
const y1 = paddingY + chartHeight - ((data[0] - displayMin) / range) * chartHeight;
const y2 = data.length > 1 ? paddingY + chartHeight - ((data[1] - displayMin) / range) * chartHeight : y1;
const currentY = y1 + (y2 - y1) * dotPosition;
// Efekt pulsowania
if (this._config.widget_effect === 'pulse') {
pulseEffect = `