power-flux-card/dist/power-flux-card.js
root f19730ff1d Fix: Secondary sensor display - Wh/EUR precision
- Separate Wh from W in getSecondaryVal: Wh values >= 1000
  now display as kWh (e.g., 794 Wh -> 0.79 kWh)
- EUR/ct/€ units now show 2 decimal places (0.28 EUR/kWh
  instead of 0.3)
- W values continue using _formatPower() as before

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:27:27 +01:00

2300 lines
100 KiB
JavaScript

/**
* Power Flux Card (Bundled)
* Generated by build.js
*/
const lang_de = {
editor: {
"card.label_import": "Import",
"card.label_export": "Export",
"editor.main_title": "Haupt Entitäten",
"editor.solar_section": "Solar/PV",
"editor.grid_section": "Netz Import/Export",
"editor.battery_section": "Batterie",
"editor.consumers_section": "Zusätzliche Verbraucher",
"editor.options_section": "Darstellung & Optionen",
"editor.flow_rate_title": "Flussraten (W) an Röhren anzeigen",
"editor.invert_battery": "Wert umkehren (+/-)",
"editor.label_toggle": "Label im Kreis anzeigen",
"editor.compact_view": "Kompakte Ansicht (evcc)",
"editor.hide_inactive": "Inaktive Röhren ausblenden",
"editor.entity": "Kombinierter Batterie Sensor (W)",
"editor.label": "Beschriftung",
"editor.icon": "Icon",
"editor.back": "Zurück",
"editor.battery_soc_label": "Ladestand (%)",
"editor.house_total_title": "🏠 Gesamtverbrauch (optional)",
"editor.house_sensor_label": "Sensor für Hausverbrauch (optional)",
"editor.house_sensor_hint": "Wird benötigt, damit das Haus-Icon anklickbar ist (more-details). Ansonsten wird der Hausverbrauch berechnet.",
"editor.consumer_1_title": "🚗 Links (Lila)",
"editor.consumer_2_title": "♨️ Mitte (Orange)",
"editor.consumer_3_title": "🏊 Rechts (Türkis)",
"editor.zoom_label": "🔍 Zoom (Standard View)",
"editor.neon_glow": "Neon Glow",
"editor.donut_chart": "Donut Chart (Grid/Haus)",
"editor.comet_tail": "Comet Tail Effect",
"editor.dashed_line": "Dashed Line Effect",
"editor.tinted_background": "Farbiger Hintergrund in Kreisen",
"editor.colored_values": "Farbige Textwerte",
"editor.hide_consumer_icons": "Icons unten ausblenden",
"editor.invert_consumer_1": "Sensorwert invertieren (+/-)",
"editor.secondary_sensor": "Zweiter Sensor (nur Anzeige)",
"editor.grid_to_battery_sensor": "Netz-zu-Batterie Sensor (W, Optional)",
"editor.grid_to_battery_hint": "Optional: separater Sensor für den Netz-zu-Batterie Fluss. Wenn leer, wird der Wert automatisch berechnet.",
"editor.grid_combined_sensor": "Kombinierter Netz-Sensor (W, Optional)",
"editor.grid_combined_hint": "Ein Sensor für Import UND Export: positiv = Import, negativ = Export. Überschreibt den kombinierten Import/Export Sensor.",
"editor.color_picker": "Bubble",
"editor.pipe_color": "Pipe",
"editor.export_color": "Export",
"editor.consumer_unit_kw": "Sensor meldet in kW",
"editor.show_consumer_always": "Verbraucher bei null Watt anzeigen",
"editor.battery_charge_sensor": "Batterie-Ladung Sensor (W, Optional)",
"editor.battery_discharge_sensor": "Batterie-Entladung Sensor (W, Optional)",
"editor.battery_separate_hint": "Optional: Separate Sensoren für Laden/Entladen. Überschreiben den Hauptsensor für die Berechnung.",
"editor.consumer_1_hide_pipe": "Pipe bei geringer Leistung ausblenden",
"editor.consumer_pipe_threshold": "Pipe-Schwellenwert (Watt)",
"editor.text_color": "Text",
"editor.icon_color": "Icon",
},
card: {
"card.label_solar": "Solar",
"card.label_grid": "Netz",
"card.label_battery": "Batterie",
"card.label_house": "Verbrauch",
"card.label_car": "E-Auto",
"card.label_heater": "Heizung",
"card.label_pool": "Pool",
}
};
const lang_en = {
editor: {
"card.label_import": "Import",
"card.label_export": "Export",
"editor.main_title": "Main Entities",
"editor.solar_section": "Solar",
"editor.grid_section": "Grid Connection",
"editor.battery_section": "Battery",
"editor.consumers_section": "Additional Consumers",
"editor.options_section": "Appearance & Options",
"editor.flow_rate_title": "Show Flow Rates (W) on pipes",
"editor.invert_battery": "Invert Power Value (+/-)",
"editor.label_toggle": "Show Label in Bubble",
"editor.compact_view": "Compact View (evcc)",
"editor.hide_inactive": "Hide Inactive Pipes",
"editor.entity": "Combined Battery Sensor (W)",
"editor.label": "Label",
"editor.icon": "Icon",
"editor.back": "Back",
"editor.battery_soc_label": "State of Charge (%)",
"editor.house_total_title": "🏠 Total Consumption (optional)",
"editor.house_sensor_label": "Sensor for House Consumption (optional)",
"editor.house_sensor_hint": "Required to make the house icon clickable (more-details). Otherwise, the house consumption is calculated.",
"editor.consumer_1_title": "🚗 Left (Purple)",
"editor.consumer_2_title": "♨️ Center (Orange)",
"editor.consumer_3_title": "🏊 Right (Cyan)",
"editor.zoom_label": "🔍 Zoom (Standard View)",
"editor.neon_glow": "Neon Glow",
"editor.donut_chart": "Donut Chart (Grid/House)",
"editor.comet_tail": "Comet Tail Effect",
"editor.dashed_line": "Dashed Line Effect",
"editor.tinted_background": "Tinted Background in Bubbles",
"editor.colored_values": "Colored Text Values",
"editor.hide_consumer_icons": "Hide Consumer Icons",
"editor.invert_consumer_1": "Invert Sensor Value (+/-)",
"editor.secondary_sensor": "Secondary Sensor (display only)",
"editor.grid_to_battery_sensor": "Grid to Battery Sensor (W, optional)",
"editor.grid_to_battery_hint": "Optional: separate sensor for grid-to-battery flow. If empty, the value is calculated automatically.",
"editor.grid_combined_sensor": "Combined Grid Sensor (W, Optional)",
"editor.grid_combined_hint": "Single sensor for import AND export: positive = import, negative = export. Overrides combined import/export sensor.",
"editor.color_picker": "Bubble Color",
"editor.pipe_color": "Pipe Color",
"editor.export_color": "Export Color",
"editor.consumer_unit_kw": "Sensor reports in kW",
"editor.show_consumer_always": "Show Consumers at zero watts",
"editor.battery_charge_sensor": "Battery Charge Sensor (W, Optional)",
"editor.battery_discharge_sensor": "Battery Discharge Sensor (W, Optional)",
"editor.battery_separate_hint": "Optional: Separate sensors for charge/discharge. Override the main sensor for calculations.",
"editor.consumer_1_hide_pipe": "Hide pipe at low power",
"editor.consumer_pipe_threshold": "Pipe Threshold (Watts)",
"editor.text_color": "Text Color",
"editor.icon_color": "Icon Color",
},
card: {
"card.label_solar": "Solar",
"card.label_grid": "Grid",
"card.label_battery": "Battery",
"card.label_house": "Consumption",
"card.label_car": "Car",
"card.label_heater": "Heater",
"card.label_pool": "Pool",
}
};
const editorTranslations = {};
const cardTranslations = {};
editorTranslations['de'] = lang_de.editor;
cardTranslations['de'] = lang_de.card;
editorTranslations['en'] = lang_en.editor;
cardTranslations['en'] = lang_en.card;
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;
};
const LitElement = customElements.get("ha-lit-element") || Object.getPrototypeOf(customElements.get("home-assistant-main"));
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
class PowerFluxCardEditor extends LitElement {
static get properties() {
return {
hass: {},
_config: { state: true },
_subView: { state: true } // Controls which sub-page is open (null = main)
};
}
setConfig(config) {
this._config = config;
}
_localize(key) {
const lang = this.hass && this.hass.language ? this.hass.language : 'en';
const dict = editorTranslations[lang] || editorTranslations['en'];
return dict[key] || editorTranslations['en'][key] || key;
}
_valueChanged(ev) {
if (!this._config || !this.hass) return;
const target = ev.target;
const key = target.configValue;
let value;
if (target.tagName === 'HA-SWITCH') {
value = target.checked;
} else if (ev.detail && 'value' in ev.detail) {
value = ev.detail.value;
} else {
value = target.value;
}
if (value === null || value === undefined) {
value = "";
}
if (key) {
const entityKeys = [
'solar', 'grid', 'grid_export', 'grid_combined',
'battery', 'battery_soc', 'grid_to_battery',
'battery_charge', 'battery_discharge',
'house',
'consumer_1', 'consumer_2', 'consumer_3',
'secondary_solar', 'secondary_grid', 'secondary_battery',
'secondary_consumer_1', 'secondary_consumer_2', 'secondary_consumer_3'
];
let newConfig = { ...this._config };
if (entityKeys.includes(key)) {
const currentEntities = newConfig.entities || {};
const newEntities = { ...currentEntities, [key]: value };
newConfig.entities = newEntities;
} else {
newConfig[key] = value;
if (key === 'show_comet_tail' && value === true) {
newConfig.show_dashed_line = false;
}
if (key === 'show_dashed_line' && value === true) {
newConfig.show_comet_tail = false;
}
}
this._config = newConfig;
fireEvent(this, "config-changed", { config: this._config });
}
}
_goSubView(view) {
this._subView = view;
}
_goBack() {
this._subView = null;
}
_clearEntity(key) {
const newConfig = { ...this._config };
const currentEntities = newConfig.entities || {};
const newEntities = { ...currentEntities, [key]: "" };
newConfig.entities = newEntities;
this._config = newConfig;
fireEvent(this, "config-changed", { config: this._config });
}
_colorChanged(key, ev) {
const newConfig = { ...this._config, [key]: ev.target.value };
this._config = newConfig;
fireEvent(this, "config-changed", { config: this._config });
}
_resetColor(key) {
const newConfig = { ...this._config };
delete newConfig[key];
this._config = newConfig;
fireEvent(this, "config-changed", { config: this._config });
}
_renderEntitySelector(entitySelectorSchema, value, configValue, label) {
const val = value || "";
return html`
<div class="entity-picker-wrapper">
<ha-selector
.hass=${this.hass}
.selector=${entitySelectorSchema}
.value=${val}
.configValue=${configValue}
.label=${label}
@value-changed=${this._valueChanged}
></ha-selector>
${val ? html`<ha-icon
class="clear-entity-btn"
icon="mdi:close-circle"
@click=${() => this._clearEntity(configValue)}
></ha-icon>` : ''}
</div>
`;
}
_renderColorPicker(key, label, defaultColor) {
const currentColor = this._config[key] || defaultColor;
const hasCustom = !!this._config[key];
return html`
<div class="color-picker-row">
<input type="color"
.value=${currentColor}
@input=${(e) => this._colorChanged(key, e)}>
<span class="color-label">${label}</span>
${hasCustom ? html`<ha-icon class="color-reset-btn"
icon="mdi:refresh"
@click=${() => this._resetColor(key)}></ha-icon>` : ''}
</div>
`;
}
_renderColorPickerQuad(bubbleKey, pipeKey, textKey, iconKey, defaultColor) {
const items = [
{ key: bubbleKey, label: this._localize('editor.color_picker'), default: defaultColor },
];
if (pipeKey) items.push({ key: pipeKey, label: this._localize('editor.pipe_color'), default: defaultColor });
items.push({ key: textKey, label: this._localize('editor.text_color'), default: defaultColor });
items.push({ key: iconKey, label: this._localize('editor.icon_color'), default: defaultColor });
return html`
<div class="color-picker-quad">
${items.map(item => {
const color = this._config[item.key] || item.default;
const hasCustom = !!this._config[item.key];
return html`
<div class="color-picker-row">
<input type="color"
.value=${color}
@input=${(e) => this._colorChanged(item.key, e)}>
<span class="color-label">${item.label}</span>
${hasCustom ? html`<ha-icon class="color-reset-btn"
icon="mdi:refresh"
@click=${() => this._resetColor(item.key)}></ha-icon>` : ''}
</div>
`;
})}
</div>
`;
}
static get styles() {
return css`
.card-config {
display: flex;
flex-direction: column;
padding-bottom: 24px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.back-btn {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
color: var(--primary-color);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--divider-color);
margin-bottom: 13px;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: rgba(var(--rgb-primary-text-color), 0.05);
}
.menu-icon {
display: flex;
align-items: center;
gap: 12px;
font-weight: bold;
}
.switch-row {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 0;
margin-top: 8px;
}
.switch-label {
font-weight: bold;
}
.section-title {
font-size: 1.1em;
font-weight: bold;
margin-top: 15px;
margin-bottom: 15px;
padding-bottom: 4px;
border-bottom: 1px solid var(--divider-color);
}
ha-selector {
width: 100%;
display: block;
margin-bottom: 12px;
}
.consumer-group {
padding: 10px;
border-radius: 8px;
border-bottom: 1px solid var(--divider-color);
margin-bottom: 12px;
}
.consumer-title {
font-weight: bold;
margin-bottom: 8px;
color: var(--primary-text-color);
}
.separator {
border-bottom: 1px solid var(--divider-color);
margin: 10px 0;
}
.entity-picker-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 4px;
}
.entity-picker-wrapper ha-selector {
flex: 1;
}
.clear-entity-btn {
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
cursor: pointer;
flex-shrink: 0;
margin-top: -12px;
}
.clear-entity-btn:hover {
color: var(--error-color, #db4437);
}
.color-picker-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.color-picker-row input[type="color"] {
-webkit-appearance: none;
border: 2px solid var(--divider-color);
border-radius: 50%;
width: 30px;
height: 30px;
padding: 2px;
cursor: pointer;
background: transparent;
}
.color-picker-row input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-picker-row input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 50%;
}
.color-label {
flex: 1;
font-size: 14px;
}
.color-reset-btn {
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
cursor: pointer;
}
.color-reset-btn:hover {
color: var(--primary-color);
}
.color-picker-quad {
display: flex;
gap: 8px;
}
.color-picker-quad .color-picker-row {
flex: 1;
}
`;
}
// --- SUBVIEW RENDERING ---
_renderSolarView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) {
return html`
<div class="header">
<div class="back-btn" @click=${this._goBack}>
<ha-icon icon="mdi:arrow-left"></ha-icon> ${this._localize('editor.back')}
</div>
<h2>${this._localize('editor.solar_section')}</h2>
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.solar, 'solar', this._localize('editor.entity'))}
<div class="separator"></div>
<ha-selector
.hass=${this.hass}
.selector=${textSelectorSchema}
.value=${this._config.solar_label}
.configValue=${'solar_label'}
.label=${this._localize('editor.label') + " (Optional)"}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector
.hass=${this.hass}
.selector=${iconSelectorSchema}
.value=${this._config.solar_icon}
.configValue=${'solar_icon'}
.label=${this._localize('editor.icon') + " (Optional)"}
@value-changed=${this._valueChanged}
></ha-selector>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_solar || "", 'secondary_solar', this._localize('editor.secondary_sensor'))}
${this._renderColorPickerQuad('color_solar', 'color_pipe_solar', 'color_text_solar', 'color_icon_solar', '#ffdd00')}
<div class="separator"></div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_label_solar === true}
.configValue=${'show_label_solar'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.label_toggle')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_flow_rate_solar !== false}
.configValue=${'show_flow_rate_solar'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.flow_rate_title')}</div>
</div>
`;
}
_renderGridView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) {
return html`
<div class="header">
<div class="back-btn" @click=${this._goBack}>
<ha-icon icon="mdi:arrow-left"></ha-icon> ${this._localize('editor.back')}
</div>
<h2>${this._localize('editor.grid_section')}</h2>
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.grid_combined || "", 'grid_combined', this._localize('editor.grid_combined_sensor'))}
<div class="separator"></div>
<div style="font-size: 0.8em; color: var(--secondary-text-color); margin-top: 4px;">
${this._localize('editor.grid_combined_hint')}
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.grid, 'grid', this._localize('card.label_import') + " (W)")}
${this._renderEntitySelector(entitySelectorSchema, entities.grid_export, 'grid_export', this._localize('card.label_export') + " (W, Optional)")}
<div class="separator"></div>
<ha-selector
.hass=${this.hass}
.selector=${textSelectorSchema}
.value=${this._config.grid_label}
.configValue=${'grid_label'}
.label=${this._localize('editor.label') + " (Optional)"}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector
.hass=${this.hass}
.selector=${iconSelectorSchema}
.value=${this._config.grid_icon}
.configValue=${'grid_icon'}
.label=${this._localize('editor.icon') + " (Optional)"}
@value-changed=${this._valueChanged}
></ha-selector>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_grid || "", 'secondary_grid', this._localize('editor.secondary_sensor'))}
${this._renderColorPickerQuad('color_grid', 'color_pipe_grid', 'color_text_grid', 'color_icon_grid', '#3b82f6')}
${this._renderColorPicker('color_export', this._localize('editor.export_color'), '#ff3333')}
<div class="separator"></div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_label_grid === true}
.configValue=${'show_label_grid'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.label_toggle')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_flow_rate_grid !== false}
.configValue=${'show_flow_rate_grid'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.flow_rate_title')}</div>
</div>
`;
}
_renderBatteryView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) {
return html`
<div class="header">
<div class="back-btn" @click=${this._goBack}>
<ha-icon icon="mdi:arrow-left"></ha-icon> ${this._localize('editor.back')}
</div>
<h2>${this._localize('editor.battery_section')}</h2>
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.battery, 'battery', this._localize('editor.entity'))}
<div class="separator"></div>
<div style="font-size: 0.8em; color: var(--secondary-text-color); margin-top: 4px;">
${this._localize('editor.battery_separate_hint')}
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.battery_charge || "", 'battery_charge', this._localize('editor.battery_charge_sensor'))}
${this._renderEntitySelector(entitySelectorSchema, entities.battery_discharge || "", 'battery_discharge', this._localize('editor.battery_discharge_sensor'))}
<div class="separator"></div>
<ha-selector
.hass=${this.hass}
.selector=${textSelectorSchema}
.value=${this._config.battery_label}
.configValue=${'battery_label'}
.label=${this._localize('editor.label') + " (Optional)"}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector
.hass=${this.hass}
.selector=${iconSelectorSchema}
.value=${this._config.battery_icon}
.configValue=${'battery_icon'}
.label=${this._localize('editor.icon') + " (Optional)"}
@value-changed=${this._valueChanged}
></ha-selector>
<div class="separator"></div>
${this._renderEntitySelector(entitySelectorSchema, entities.battery_soc, 'battery_soc', this._localize('editor.battery_soc_label'))}
<div class="separator"></div>
<div style="font-size: 0.8em; color: var(--secondary-text-color); margin-top: 4px;">
${this._localize('editor.grid_to_battery_hint')}
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.grid_to_battery || "", 'grid_to_battery', this._localize('editor.grid_to_battery_sensor'))}
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_battery || "", 'secondary_battery', this._localize('editor.secondary_sensor'))}
${this._renderColorPickerQuad('color_battery', 'color_pipe_battery', 'color_text_battery', 'color_icon_battery', '#00ff88')}
<div class="separator"></div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_label_battery === true}
.configValue=${'show_label_battery'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.label_toggle')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_flow_rate_battery !== false}
.configValue=${'show_flow_rate_battery'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.flow_rate_title')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.invert_battery === true}
.configValue=${'invert_battery'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.invert_battery')}</div>
</div>
`;
}
_renderConsumersView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) {
return html`
<div class="header">
<div class="back-btn" @click=${this._goBack}>
<ha-icon icon="mdi:arrow-left"></ha-icon> ${this._localize('editor.back')}
</div>
<h2>${this._localize('editor.consumers_section')}</h2>
</div>
<div class="consumer-group">
<div class="consumer-title">${this._localize('editor.house_total_title')}</div>
${this._renderEntitySelector(entitySelectorSchema, entities.house || "", 'house', this._localize('editor.house_sensor_label'))}
<div style="font-size: 0.8em; color: var(--secondary-text-color); margin-top: 4px;">
${this._localize('editor.house_sensor_hint')}
</div>
${this._renderColorPickerQuad('color_house', null, 'color_text_house', 'color_icon_house', '#ff0080')}
</div>
<div class="consumer-group">
<div class="consumer-title" style="color: #a855f7;">${this._localize('editor.consumer_1_title')}</div>
${this._renderEntitySelector(entitySelectorSchema, entities.consumer_1, 'consumer_1', this._localize('editor.entity'))}
<ha-selector
.hass=${this.hass}
.selector=${textSelectorSchema}
.value=${this._config.consumer_1_label}
.configValue=${'consumer_1_label'}
.label=${this._localize('editor.label')}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector
.hass=${this.hass}
.selector=${iconSelectorSchema}
.value=${this._config.consumer_1_icon}
.configValue=${'consumer_1_icon'}
.label=${this._localize('editor.icon')}
@value-changed=${this._valueChanged}
></ha-selector>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 8px;">
<span>${this._localize('editor.invert_consumer_1')}</span>
<ha-switch
.checked=${this._config.invert_consumer_1 === true}
.configValue=${'invert_consumer_1'}
@change=${this._valueChanged}
></ha-switch>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 8px;">
<span>${this._localize('editor.consumer_1_hide_pipe')}</span>
<ha-switch
.checked=${this._config.consumer_1_hide_pipe === true}
.configValue=${'consumer_1_hide_pipe'}
@change=${this._valueChanged}
></ha-switch>
</div>
${this._config.consumer_1_hide_pipe === true ? html`
<ha-selector
.hass=${this.hass}
.selector=${{ number: { min: 0, max: 2000, step: 10, mode: "slider" } }}
.value=${this._config.consumer_1_pipe_threshold !== undefined ? this._config.consumer_1_pipe_threshold : 0}
.configValue=${'consumer_1_pipe_threshold'}
.label=${this._localize('editor.consumer_pipe_threshold')}
@value-changed=${this._valueChanged}
></ha-selector>
` : ''}
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 8px; margin-bottom: 8px;">
<span>${this._localize('editor.consumer_unit_kw')}</span>
<ha-switch
.checked=${this._config.consumer_1_unit_kw === true}
.configValue=${'consumer_1_unit_kw'}
@change=${this._valueChanged}
></ha-switch>
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_consumer_1 || "", 'secondary_consumer_1', this._localize('editor.secondary_sensor'))}
${this._renderColorPickerQuad('color_consumer_1', 'color_pipe_consumer_1', 'color_text_consumer_1', 'color_icon_consumer_1', '#a855f7')}
</div>
<div class="consumer-group">
<div class="consumer-title" style="color: #f97316;">${this._localize('editor.consumer_2_title')}</div>
${this._renderEntitySelector(entitySelectorSchema, entities.consumer_2, 'consumer_2', this._localize('editor.entity'))}
<ha-selector
.hass=${this.hass}
.selector=${textSelectorSchema}
.value=${this._config.consumer_2_label}
.configValue=${'consumer_2_label'}
.label=${this._localize('editor.label')}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector
.hass=${this.hass}
.selector=${iconSelectorSchema}
.value=${this._config.consumer_2_icon}
.configValue=${'consumer_2_icon'}
.label=${this._localize('editor.icon')}
@value-changed=${this._valueChanged}
></ha-selector>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 8px; margin-bottom: 8px;">
<span>${this._localize('editor.consumer_unit_kw')}</span>
<ha-switch
.checked=${this._config.consumer_2_unit_kw === true}
.configValue=${'consumer_2_unit_kw'}
@change=${this._valueChanged}
></ha-switch>
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_consumer_2 || "", 'secondary_consumer_2', this._localize('editor.secondary_sensor'))}
${this._renderColorPickerQuad('color_consumer_2', 'color_pipe_consumer_2', 'color_text_consumer_2', 'color_icon_consumer_2', '#f97316')}
</div>
<div class="consumer-group">
<div class="consumer-title" style="color: #06b6d4;">${this._localize('editor.consumer_3_title')}</div>
${this._renderEntitySelector(entitySelectorSchema, entities.consumer_3, 'consumer_3', this._localize('editor.entity'))}
<ha-selector
.hass=${this.hass}
.selector=${textSelectorSchema}
.value=${this._config.consumer_3_label}
.configValue=${'consumer_3_label'}
.label=${this._localize('editor.label')}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector
.hass=${this.hass}
.selector=${iconSelectorSchema}
.value=${this._config.consumer_3_icon}
.configValue=${'consumer_3_icon'}
.label=${this._localize('editor.icon')}
@value-changed=${this._valueChanged}
></ha-selector>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 8px; margin-bottom: 8px;">
<span>${this._localize('editor.consumer_unit_kw')}</span>
<ha-switch
.checked=${this._config.consumer_3_unit_kw === true}
.configValue=${'consumer_3_unit_kw'}
@change=${this._valueChanged}
></ha-switch>
</div>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_consumer_3 || "", 'secondary_consumer_3', this._localize('editor.secondary_sensor'))}
${this._renderColorPickerQuad('color_consumer_3', 'color_pipe_consumer_3', 'color_text_consumer_3', 'color_icon_consumer_3', '#06b6d4')}
</div>
`;
}
render() {
if (!this.hass || !this._config) {
return html``;
}
const entities = this._config.entities || {};
const entitySelectorSchema = { entity: { domain: ["sensor", "input_number"] } };
const textSelectorSchema = { text: {} };
const iconSelectorSchema = { icon: {} };
// SUBVIEW ROUTING
if (this._subView === 'solar') return this._renderSolarView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema);
if (this._subView === 'grid') return this._renderGridView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema);
if (this._subView === 'battery') return this._renderBatteryView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema);
if (this._subView === 'consumers') return this._renderConsumersView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema);
// MAIN MENU VIEW
return html`
<div class="card-config">
<div class="section-title">${this._localize('editor.main_title')}</div>
<div class="menu-item" @click=${() => this._goSubView('solar')}>
<div class="menu-icon"><ha-icon icon="mdi:solar-power"></ha-icon> ${this._localize('editor.solar_section')}</div>
<ha-icon icon="mdi:chevron-right"></ha-icon>
</div>
<div class="menu-item" @click=${() => this._goSubView('grid')}>
<div class="menu-icon"><ha-icon icon="mdi:transmission-tower"></ha-icon> ${this._localize('editor.grid_section')}</div>
<ha-icon icon="mdi:chevron-right"></ha-icon>
</div>
<div class="menu-item" @click=${() => this._goSubView('battery')}>
<div class="menu-icon"><ha-icon icon="mdi:battery-high"></ha-icon> ${this._localize('editor.battery_section')}</div>
<ha-icon icon="mdi:chevron-right"></ha-icon>
</div>
<div class="menu-item" @click=${() => this._goSubView('consumers')}>
<div class="menu-icon"><ha-icon icon="mdi:devices"></ha-icon> ${this._localize('editor.consumers_section')}</div>
<ha-icon icon="mdi:chevron-right"></ha-icon>
</div>
<div class="section-title">${this._localize('editor.options_section')}</div>
<div>
<ha-selector
.hass=${this.hass}
.selector=${{ number: { min: 0.5, max: 1.5, step: 0.05, mode: "slider" } }}
.value=${this._config.zoom !== undefined ? this._config.zoom : 0.9}
.configValue=${'zoom'}
.label=${this._localize('editor.zoom_label')}
@value-changed=${this._valueChanged}
></ha-selector>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_neon_glow !== false}
.configValue=${'show_neon_glow'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.neon_glow')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_donut_border === true}
.configValue=${'show_donut_border'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.donut_chart')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_comet_tail === true}
.configValue=${'show_comet_tail'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.comet_tail')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_dashed_line === true}
.configValue=${'show_dashed_line'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.dashed_line')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_tinted_background === true}
.configValue=${'show_tinted_background'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.tinted_background')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.use_colored_values === true}
.configValue=${'use_colored_values'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.colored_values')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.hide_consumer_icons === true}
.configValue=${'hide_consumer_icons'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.hide_consumer_icons')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.hide_inactive_flows !== false}
.configValue=${'hide_inactive_flows'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.hide_inactive')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.show_consumer_always === true}
.configValue=${'show_consumer_always'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.show_consumer_always')}</div>
</div>
<div class="switch-row">
<ha-switch
.checked=${this._config.compact_view === true}
.configValue=${'compact_view'}
@change=${this._valueChanged}
></ha-switch>
<div class="switch-label">${this._localize('editor.compact_view')}</div>
</div>
</div>
`;
}
}
customElements.define("power-flux-card-editor", PowerFluxCardEditor);
console.log(
"%c⚡ Power Flux Card v_2.4 ready",
"background: #d19525ff; color: #000; padding: 2px 6px; border-radius: 4px; font-weight: bold;"
);
(function (lang_en, lang_de) {
const cardTranslations = {
"en": lang_en.card,
"de": lang_de.card
};
const LitElement = customElements.get("ha-lit-element") || Object.getPrototypeOf(customElements.get("home-assistant-main"));
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
class PowerFluxCard extends LitElement {
static get properties() {
return {
hass: {},
config: {},
_cardWidth: { state: true },
};
}
_localize(key) {
const lang = this.hass && this.hass.language ? this.hass.language : 'en';
const dict = cardTranslations[lang] || cardTranslations['en'];
return dict[key] || cardTranslations['en'][key] || key;
}
static async getConfigElement() {
return document.createElement("power-flux-card-editor");
}
static getStubConfig() {
return {
zoom: 0.9,
compact_view: false,
consumer_1_unit_kw: false,
consumer_2_unit_kw: false,
consumer_3_unit_kw: false,
show_consumer_always: false,
consumer_1_hide_pipe: false,
consumer_1_pipe_threshold: 0,
show_donut_border: false,
show_neon_glow: true,
show_comet_tail: false,
show_dashed_line: false,
show_tinted_background: false,
hide_inactive_flows: true,
show_flow_rate_solar: true,
show_flow_rate_grid: true,
show_flow_rate_battery: true,
show_label_solar: false,
show_label_grid: false,
show_label_battery: false,
show_label_house: false,
use_colored_values: false,
hide_consumer_icons: false,
entities: {
solar: "",
grid: "",
grid_export: "",
grid_combined: "",
battery: "",
battery_soc: "",
battery_charge: "",
battery_discharge: "",
house: "",
consumer_1: "",
consumer_2: "",
consumer_3: ""
}
};
}
_handleClick(entityId) {
if (!entityId) return;
const event = new Event("hass-more-info", {
bubbles: true,
composed: true,
});
event.detail = { entityId };
this.dispatchEvent(event);
}
setConfig(config) {
if (!config.entities) {
// Init allow
}
this.config = config;
}
firstUpdated() {
this._resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (entry.contentRect.width > 0) {
this._cardWidth = entry.contentRect.width;
}
}
});
this._resizeObserver.observe(this);
}
updated(changedProps) {
super.updated(changedProps);
if (changedProps.has('hass') && this.hass) {
const isDark = this.hass.themes?.darkMode !== false;
if (isDark) {
this.removeAttribute('data-theme-light');
} else {
this.setAttribute('data-theme-light', '');
}
}
// Apply custom colors from config
if (this.config) {
const colorMap = {
'color_solar': '--neon-yellow',
'color_grid': '--neon-blue',
'color_battery': '--neon-green',
'color_export': '--export-color',
'color_consumer_1': '--consumer-1-color',
'color_consumer_2': '--consumer-2-color',
'color_consumer_3': '--consumer-3-color',
'color_pipe_solar': '--pipe-solar-color',
'color_pipe_grid': '--pipe-grid-color',
'color_pipe_battery': '--pipe-battery-color',
'color_pipe_consumer_1': '--pipe-consumer-1-color',
'color_pipe_consumer_2': '--pipe-consumer-2-color',
'color_pipe_consumer_3': '--pipe-consumer-3-color',
'color_house': '--neon-pink',
'color_icon_solar': '--icon-solar-color',
'color_icon_grid': '--icon-grid-color',
'color_icon_battery': '--icon-battery-color',
'color_icon_house': '--icon-house-color',
'color_icon_consumer_1': '--icon-consumer-1-color',
'color_icon_consumer_2': '--icon-consumer-2-color',
'color_icon_consumer_3': '--icon-consumer-3-color',
'color_text_solar': '--text-solar-color',
'color_text_grid': '--text-grid-color',
'color_text_battery': '--text-battery-color',
'color_text_house': '--text-house-color',
'color_text_consumer_1': '--text-consumer-1-color',
'color_text_consumer_2': '--text-consumer-2-color',
'color_text_consumer_3': '--text-consumer-3-color',
};
for (const [configKey, cssVar] of Object.entries(colorMap)) {
if (this.config[configKey]) {
this.style.setProperty(cssVar, this.config[configKey]);
} else {
this.style.removeProperty(cssVar);
}
}
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
}
static get styles() {
return css`
:host {
display: block;
--neon-yellow: #ffdd00;
--neon-blue: #3b82f6;
--neon-green: #00ff88;
--neon-pink: #ff0080;
--neon-red: #ff3333;
--export-purple: #a855f7;
--export-color: #ff3333;
--consumer-1-color: #a855f7;
--consumer-2-color: #f97316;
--consumer-3-color: #06b6d4;
--pipe-solar-color: var(--neon-yellow);
--pipe-grid-color: var(--neon-blue);
--pipe-battery-color: var(--neon-green);
--pipe-consumer-1-color: var(--consumer-1-color);
--pipe-consumer-2-color: var(--consumer-2-color);
--pipe-consumer-3-color: var(--consumer-3-color);
--icon-solar-color: var(--neon-yellow);
--icon-grid-color: var(--neon-blue);
--icon-battery-color: var(--neon-green);
--icon-house-color: var(--neon-pink);
--icon-consumer-1-color: var(--consumer-1-color);
--icon-consumer-2-color: var(--consumer-2-color);
--icon-consumer-3-color: var(--consumer-3-color);
--text-solar-color: var(--neon-yellow);
--text-grid-color: var(--neon-blue);
--text-battery-color: var(--neon-green);
--text-house-color: var(--neon-pink);
--text-consumer-1-color: var(--consumer-1-color);
--text-consumer-2-color: var(--consumer-2-color);
--text-consumer-3-color: var(--consumer-3-color);
--flow-dasharray: 0 380;
}
:host([data-theme-light]) {
--neon-yellow: #c8a800;
--neon-blue: #2563eb;
--neon-green: #059669;
--neon-pink: #db2777;
--neon-red: #dc2626;
--export-purple: #7c3aed;
--export-color: #dc2626;
--consumer-1-color: #7c3aed;
--consumer-2-color: #ea580c;
--consumer-3-color: #0891b2;
}
ha-card {
padding: 0;
position: relative;
overflow: hidden;
transition: height 0.3s ease;
}
/* --- COMPACT VIEW STYLES --- */
.compact-container {
padding: 16px 20px;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 120px;
box-sizing: border-box;
}
.compact-bracket {
height: 24px;
width: 100%;
position: relative;
}
.bracket-svg {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: visible; /* Important for icons */
}
.bracket-line {
fill: none;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
transition: d 0.5s ease;
}
.compact-icon-wrapper {
position: absolute;
top: -6px; /* Default top, overridden inline */
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
transition: left 0.5s ease;
}
.compact-icon {
--mdc-icon-size: 20px;
}
.compact-bar-wrapper {
height: 36px;
width: 100%;
background: var(--card-background-color, #333);
border-radius: 5px;
margin: 4px 0;
display: flex;
overflow: hidden;
position: relative;
}
.bar-segment {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: bold;
color: black;
transition: width 0.5s ease;
white-space: nowrap;
overflow: hidden;
}
/* --- STANDARD VIEW STYLES --- */
.scale-wrapper {
width: 420px;
transform-origin: top left;
transition: transform 0.1s linear;
}
.absolute-container {
position: relative;
width: 100%;
transition: top 0.3s ease;
}
.bubble {
width: 90px;
height: 90px;
border-radius: 50%;
background: transparent;
border: 2px solid var(--divider-color, #333);
display: block;
position: absolute;
z-index: 2;
transition: all 0.3s ease;
box-sizing: border-box;
cursor: pointer;
}
.bubble.tinted { background: rgba(255, 255, 255, 0.05); }
.bubble.tinted.solar { background: color-mix(in srgb, var(--neon-yellow), transparent 85%); }
.bubble.tinted.grid { background: color-mix(in srgb, var(--neon-blue), transparent 85%); }
.bubble.tinted.grid.exporting { background: color-mix(in srgb, var(--export-color), transparent 85%); }
.bubble.grid.exporting { border-color: var(--export-color); }
.bubble.tinted.battery { background: color-mix(in srgb, var(--neon-green), transparent 85%); }
.bubble.tinted.c1 { background: color-mix(in srgb, var(--consumer-1-color), transparent 85%); }
.bubble.tinted.c2 { background: color-mix(in srgb, var(--consumer-2-color), transparent 85%); }
.bubble.tinted.c3 { background: color-mix(in srgb, var(--consumer-3-color), transparent 85%); }
.bubble.house { border-color: var(--neon-pink); }
.bubble.house.tinted { background: color-mix(in srgb, var(--neon-pink), transparent 85%); }
.bubble.house.donut { border: none !important; --house-gradient: var(--neon-pink); background: transparent; }
.bubble.house.donut.tinted { background: color-mix(in srgb, var(--neon-pink), transparent 85%); }
.bubble.house.donut::before {
content: ""; position: absolute; inset: 0; border-radius: 50%; padding: 4px;
background: var(--house-gradient);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; z-index: -1; pointer-events: none;
}
.bubble.grid.donut { border: none !important; background: transparent; }
.bubble.grid.donut.tinted { background: color-mix(in srgb, var(--neon-blue), transparent 85%); }
.bubble.grid.donut.tinted.exporting { background: color-mix(in srgb, var(--export-color), transparent 85%); }
.bubble.grid.donut::before {
content: ""; position: absolute; inset: 0; border-radius: 50%; padding: 4px;
background: var(--grid-gradient, var(--neon-blue));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; mask-composite: exclude; z-index: -1; pointer-events: none;
}
.icon-svg, .icon-custom {
width: 33px; height: 33px; position: absolute; top: 10px; left: 50%; margin-left: -17px; z-index: 2; display: block;
}
.icon-custom { --mdc-icon-size: 34px; }
.sub {
font-size: 9px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.5px;
line-height: 1.1; z-index: 2; position: absolute; top: 46px; left: 0; width: 100%; text-align: center; margin: 0; pointer-events: none;
}
.sub.secondary-val {
text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 10px;
}
.value {
font-weight: bold; font-size: 15px; white-space: nowrap; z-index: 2; transition: color 0.3s ease;
line-height: 1.2; position: absolute; bottom: 11px; left: 0; width: 100%; text-align: center; margin: 0;
}
.direction-arrow { font-size: 12px; margin-right: 0px; vertical-align: top; }
@keyframes spin { 100% { transform: rotate(360deg); } }
.spin-slow { animation: spin 12s linear infinite; transform-origin: center; }
@keyframes pulse-opacity { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.pulse { animation: pulse-opacity 2s ease-in-out infinite; }
@keyframes float-y { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
.float { animation: float-y 3s ease-in-out infinite; }
.solar { border-color: var(--neon-yellow); }
.battery { border-color: var(--neon-green); }
.grid { border-color: var(--neon-blue); }
.c1 { border-color: var(--consumer-1-color); }
.c2 { border-color: var(--consumer-2-color); }
.c3 { border-color: var(--consumer-3-color); }
.inactive { border-color: var(--secondary-text-color); }
.glow.solar { box-shadow: 0 0 15px color-mix(in srgb, var(--neon-yellow), transparent 60%); }
.glow.battery { box-shadow: 0 0 15px color-mix(in srgb, var(--neon-green), transparent 60%); }
.glow.grid { box-shadow: 0 0 15px color-mix(in srgb, var(--neon-blue), transparent 60%); }
.glow.grid.exporting { box-shadow: 0 0 15px color-mix(in srgb, var(--export-color), transparent 60%); }
.glow.c1 { box-shadow: 0 0 15px color-mix(in srgb, var(--consumer-1-color), transparent 60%); }
.glow.c2 { box-shadow: 0 0 15px color-mix(in srgb, var(--consumer-2-color), transparent 60%); }
.glow.c3 { box-shadow: 0 0 15px color-mix(in srgb, var(--consumer-3-color), transparent 60%); }
.node-solar { top: 70px; left: 5px; }
.node-grid { top: 70px; left: 165px; }
.node-battery { top: 70px; left: 325px; }
.node-house { top: 220px; left: 165px; }
.node-c1 { top: 370px; left: 5px; }
.node-c2 { top: 370px; left: 165px; }
.node-c3 { top: 370px; left: 325px; }
svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; }
.bg-path { fill: none; stroke-width: 6; transition: opacity 0.3s ease; }
.bg-solar { stroke: var(--pipe-solar-color); }
.bg-grid { stroke: var(--pipe-grid-color); }
.bg-battery { stroke: var(--pipe-battery-color); }
.bg-export { stroke: var(--export-color); }
.bg-c1 { stroke: var(--pipe-consumer-1-color); }
.bg-c2 { stroke: var(--pipe-consumer-2-color); }
.bg-c3 { stroke: var(--pipe-consumer-3-color); }
.flow-line {
fill: none; stroke-width: var(--flow-stroke-width, 8px); stroke-linecap: round; stroke-dasharray: var(--flow-dasharray);
animation: dash linear infinite; opacity: 0; transition: opacity 0.5s;
}
.flow-solar { stroke: var(--pipe-solar-color); }
.flow-grid { stroke: var(--pipe-grid-color); }
.flow-battery { stroke: var(--pipe-battery-color); }
.flow-export { stroke: var(--export-color); }
@keyframes dash { to { stroke-dashoffset: -1500; } }
.flow-text {
font-size: 10px; font-weight: bold; text-anchor: middle; fill: #fff; transition: opacity 0.3s ease;
}
.flow-text.no-shadow { filter: none; }
.text-solar { fill: var(--pipe-solar-color); }
.text-grid { fill: var(--pipe-grid-color); }
.text-export { fill: var(--export-color); }
.text-battery { fill: var(--pipe-battery-color); }
`;
}
// --- SVG ICON RENDERER ---
_renderIcon(type, val = 0, colorOverride = null) {
if (type === 'solar') {
const animate = Math.round(val) > 0 ? 'spin-slow' : '';
const color = colorOverride || 'var(--icon-solar-color)';
return html`<svg class="icon-svg ${animate}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>`;
}
if (type === 'grid') {
const animate = Math.round(val) > 0 ? 'pulse' : '';
const color = colorOverride || 'var(--icon-grid-color)';
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L12 22"></path><path d="M5 8L19 8"></path><path d="M4 14L20 14"></path><path d="M2 22L22 22"></path><circle class="${animate}" cx="12" cy="4" r="4" fill="${color}" stroke="none"></circle></svg>`;
}
if (type === 'battery') {
const soc = Math.min(Math.max(val, 0), 100) / 100;
const rectHeight = 14 * soc;
const rectY = 18 - rectHeight;
const strokeColor = colorOverride || 'var(--icon-battery-color)';
const rectColor = soc > 0.2 ? strokeColor : 'var(--neon-red)';
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="12" height="16" rx="2" ry="2"></rect><line x1="10" y1="2" x2="14" y2="2"></line><rect x="7" y="${rectY}" width="10" height="${rectHeight}" fill="${rectColor}" stroke="none"></rect></svg>`;
}
if (type === 'house') {
const strokeColor = colorOverride || 'var(--icon-house-color)';
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>`;
}
if (type === 'car') {
const c = colorOverride || 'var(--icon-consumer-1-color)';
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"></path><circle cx="7" cy="17" r="2"></circle><circle cx="17" cy="17" r="2"></circle><path d="M14 17h-5"></path></svg>`;
}
if (type === 'heater') {
const c = colorOverride || 'var(--icon-consumer-2-color)';
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20a4 4 0 0 0 4-4V8a4 4 0 0 0-8 0v8a4 4 0 0 0 4 4z"></path><path class="float" style="animation-delay: 0s;" d="M8 4c0-1.5 1-2 2-2s2 .5 2 2"></path><path class="float" style="animation-delay: 0.5s;" d="M14 4c0-1.5 1-2 2-2s2 .5 2 2"></path></svg>`;
}
if (type === 'pool') {
const c = colorOverride || 'var(--icon-consumer-3-color)';
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="${c}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h20"></path><path class="float" d="M2 16c2.5 0 2.5-2 5-2s2.5 2 5 2 2.5-2 5-2 2.5 2 5 2"></path><path d="M12 2v6"></path><path d="M9 5h6"></path></svg>`;
}
return html``;
}
_formatPower(val) {
if (val === 0) return "0";
if (Math.abs(val) >= 1000) {
return (val / 1000).toFixed(1) + " kW";
}
return Math.round(val) + " W";
}
_getConsumerColor(index) {
const style = getComputedStyle(this);
return style.getPropertyValue(`--consumer-${index}-color`).trim() || ['#a855f7', '#f97316', '#06b6d4'][index - 1];
}
_getConsumerPipeColor(index) {
const style = getComputedStyle(this);
return style.getPropertyValue(`--pipe-consumer-${index}-color`).trim() || this._getConsumerColor(index);
}
// --- DOM NODE SVG GENERATOR ---
_renderSVGPath(d, color) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", d);
path.setAttribute("class", "bracket-line");
path.setAttribute("stroke", color);
path.setAttribute("stroke-width", "1.5");
path.setAttribute("fill", "none");
path.style.stroke = color;
path.style.fill = "none";
return path;
}
// --- SQUARE BRACKET GENERATOR ---
_createBracketPath(startPx, widthPx, direction) {
if (widthPx < 5) return "";
const r = 5;
const startX = startPx;
const endX = startPx + widthPx;
let yBase, yLine;
if (direction === 'down') {
yBase = 24;
yLine = 4;
} else {
yBase = 0;
yLine = 20;
}
const height = Math.abs(yBase - yLine);
const rEff = Math.min(r, height / 2, widthPx / 2);
const yCorner = direction === 'down' ? yLine + rEff : yLine - rEff;
return `
M ${startX} ${yBase}
L ${startX} ${yCorner}
Q ${startX} ${yLine} ${startX + rEff} ${yLine}
L ${endX - rEff} ${yLine}
Q ${endX} ${yLine} ${endX} ${yCorner}
L ${endX} ${yBase}
`;
}
// --- RENDER COMPACT VIEW ---
_renderCompactView(entities) {
// 1. Get Values
const getVal = (entity) => {
const state = this.hass.states[entity];
return state ? parseFloat(state.state) || 0 : 0;
};
const getValKw = (entity, isKw) => {
return getVal(entity) * (isKw ? 1000 : 1);
};
const solar = entities.solar ? Math.max(0, getVal(entities.solar)) : 0;
const hasGridCombined = !!(entities.grid_combined && entities.grid_combined !== "");
const gridCombinedVal = hasGridCombined ? getVal(entities.grid_combined) : 0;
const gridMain = hasGridCombined ? gridCombinedVal : (entities.grid ? getVal(entities.grid) : 0);
const gridExportSensor = entities.grid_export ? getVal(entities.grid_export) : 0;
let battery = entities.battery ? getVal(entities.battery) : 0;
if (this.config.invert_battery) {
battery *= -1;
}
let c1Val = entities.consumer_1 ? getValKw(entities.consumer_1, this.config.consumer_1_unit_kw === true) : 0; // EV Value
if (this.config.invert_consumer_1) { c1Val *= -1; }
c1Val = Math.abs(c1Val);
// 2. Logic Calculation
let gridImport = 0;
let gridExport = 0;
if (hasGridCombined) {
// COMBINED SENSOR: positive = import, negative = export
gridImport = gridCombinedVal > 0 ? gridCombinedVal : 0;
gridExport = gridCombinedVal < 0 ? Math.abs(gridCombinedVal) : 0;
} else if (entities.grid_export && entities.grid_export !== "") {
gridImport = gridMain > 0 ? gridMain : 0;
gridExport = Math.abs(gridExportSensor);
} else {
gridImport = gridMain > 0 ? gridMain : 0;
gridExport = gridMain < 0 ? Math.abs(gridMain) : 0;
}
// Check for separate battery charge/discharge sensors
const hasBattChargeSensor = !!(entities.battery_charge && entities.battery_charge !== "");
const hasBattDischargeSensor = !!(entities.battery_discharge && entities.battery_discharge !== "");
const batteryCharge = hasBattChargeSensor ? Math.abs(getVal(entities.battery_charge)) : (battery > 0 ? battery : 0);
const batteryDischarge = hasBattDischargeSensor ? Math.abs(getVal(entities.battery_discharge)) : (battery < 0 ? Math.abs(battery) : 0);
let solarToBatt = 0;
let gridToBatt = 0;
if (batteryCharge > 0) {
const hasGridToBattSensor = !!(entities.grid_to_battery && entities.grid_to_battery !== "");
if (hasGridToBattSensor) {
gridToBatt = Math.abs(getVal(entities.grid_to_battery));
solarToBatt = Math.max(0, batteryCharge - gridToBatt);
} else {
if (solar >= batteryCharge) {
solarToBatt = batteryCharge;
gridToBatt = 0;
} else {
solarToBatt = solar;
gridToBatt = batteryCharge - solar;
}
}
}
const solarTotalToCons = Math.max(0, solar - solarToBatt - gridExport);
const gridTotalToCons = Math.max(0, gridImport - gridToBatt);
const battTotalToCons = batteryDischarge;
const totalCons = solarTotalToCons + gridTotalToCons + battTotalToCons;
// Calculate Splits
let evPower = 0;
let housePower = totalCons;
if (c1Val > 0 && totalCons > 0) {
evPower = Math.min(c1Val, totalCons);
housePower = totalCons - evPower;
}
// Calculate Total Bar Width (Flux)
// The Bar represents: Battery Discharge + Solar + Grid Import
// This MUST equal: House + EV + Export + Battery Charge
// SOURCES (for Bar Segments)
const srcBattery = batteryDischarge;
const srcSolar = solar; // Solar includes Export + Charge + Cons
const srcGrid = gridImport;
const totalFlux = srcBattery + srcSolar + srcGrid;
// DESTINATIONS (for Bottom Brackets)
const destHouse = housePower;
const destEV = evPower;
const destExport = gridExport;
// Note: Battery Charge is also a destination (internal flow), but usually not bracketed if we only want "Consumers"
// If we don't bracket Charge, there will be a gap. We can accept that or add a Charge bracket.
// Given user request "Only EV... and Grid Export", we stick to those.
const threshold = 0.1;
const availableWidth = (this._cardWidth && this._cardWidth > 0) ? this._cardWidth : (this.offsetWidth || 400);
const fullWidth = availableWidth - 40;
if (totalFlux <= threshold) {
return html`<ha-card><div class="compact-container">Waiting for data...</div></ha-card>`;
}
// --- GENERATE BAR SEGMENTS (Aggregated by Source) ---
// Order: Battery -> Solar -> Grid
const barSegments = [];
let currentX = 0;
const addSegment = (val, color, type, label, entityId) => {
if (val <= threshold) return;
const pct = val / totalFlux;
const width = pct * fullWidth;
barSegments.push({
val,
color,
widthPct: pct * 100,
widthPx: width,
startPx: currentX,
type,
label,
entityId
});
currentX += width;
}
addSegment(srcBattery, 'var(--neon-green)', 'battery', 'battery', entities.battery);
addSegment(srcSolar, 'var(--neon-yellow)', 'solar', 'solar', entities.solar);
addSegment(srcGrid, 'var(--neon-blue)', 'grid', 'grid', entities.grid_combined || entities.grid);
// --- GENERATE TOP BRACKETS (Based on Bar Segments) ---
const topBrackets = barSegments.map(s => {
const path = this._createBracketPath(s.startPx, s.widthPx, 'down');
let icon = '';
let iconColor = '';
if (s.type === 'solar') { icon = 'mdi:weather-sunny'; iconColor = 'var(--icon-solar-color)'; }
if (s.type === 'grid') { icon = 'mdi:transmission-tower'; iconColor = 'var(--icon-grid-color)'; }
if (s.type === 'battery') { icon = 'mdi:battery-high'; iconColor = 'var(--icon-battery-color)'; }
return { path, width: s.widthPx, center: s.startPx + (s.widthPx / 2), icon, iconColor, val: s.val, entityId: s.entityId };
});
// --- GENERATE BOTTOM BRACKETS (Independent Calculation) ---
// Order: House -> EV -> Export
const bottomBrackets = [];
let bottomX = 0;
const addBottomBracket = (val, type, entityId = null) => {
if (val <= threshold) return;
const pct = val / totalFlux;
const width = pct * fullWidth;
let icon = '';
let iconColor = '';
if (type === 'house') { icon = 'mdi:home'; iconColor = 'var(--icon-house-color)'; }
if (type === 'car') { icon = 'mdi:car-electric'; iconColor = 'var(--icon-consumer-1-color)'; }
if (type === 'export') { icon = 'mdi:arrow-right-box'; iconColor = 'var(--export-color)'; }
if (type === 'battery') { icon = 'mdi:battery-charging-high'; iconColor = 'var(--icon-battery-color)'; }
const path = this._createBracketPath(bottomX, width, 'up');
bottomBrackets.push({
path,
width: width,
center: bottomX + (width / 2),
icon,
iconColor,
val,
entityId
});
bottomX += width;
};
addBottomBracket(destHouse, 'house', entities.house);
addBottomBracket(destEV, 'car', entities.consumer_1);
addBottomBracket(destExport, 'export', entities.grid_combined || entities.grid_export || entities.grid);
addBottomBracket(batteryCharge, 'battery', entities.battery);
// Note: If there is Battery Charging happening, bottomX will not reach fullWidth.
// This leaves a gap at the end (or between segments depending on logic), which is visually correct
// as "Internal/Stored Energy" is not an external output.
return html`
<ha-card>
<div class="compact-container">
<!-- TOP BRACKETS -->
<div class="compact-bracket">
<svg class="bracket-svg" width="100%" height="100%">
${topBrackets.map(b => this._renderSVGPath(b.path, b.iconColor))}
</svg>
${topBrackets.map(b => b.width > 20 ? html`
<div class="compact-icon-wrapper"
style="left: ${b.center}px; transform: translateX(-50%); top: 4px; cursor: ${b.entityId ? 'pointer' : 'default'};"
title="${this._formatPower(b.val)}"
@click=${() => b.entityId && this._handleClick(b.entityId)}>
<ha-icon icon="${b.icon}" class="compact-icon" style="color: ${b.iconColor};"></ha-icon>
</div>` : '')}
</div>
<!-- MAIN BAR -->
<div class="compact-bar-wrapper">
${barSegments.map(s => {
const textColor = s.type === 'solar' && this.config.color_text_solar ? 'var(--text-solar-color)'
: s.type === 'grid' && this.config.color_text_grid ? 'var(--text-grid-color)'
: s.type === 'battery' && this.config.color_text_battery ? 'var(--text-battery-color)'
: (s.color === 'var(--export-purple)' ? 'white' : 'black');
return html`
<div class="bar-segment"
style="width: ${s.widthPct}%; background: ${s.color}; color: ${textColor}; cursor: ${s.entityId ? 'pointer' : 'default'};"
title="${this._formatPower(s.val)}"
@click=${() => s.entityId && this._handleClick(s.entityId)}>
${s.widthPx > 35 ? this._formatPower(s.val) : ''}
</div>
`})}
</div>
<!-- BOTTOM BRACKETS -->
<div class="compact-bracket">
<svg class="bracket-svg" width="100%" height="100%">
${bottomBrackets.map(b => this._renderSVGPath(b.path, b.iconColor))}
</svg>
${bottomBrackets.map(b => b.width > 20 ? html`
<div class="compact-icon-wrapper"
style="left: ${b.center}px; transform: translateX(-50%); top: -3px; cursor: ${b.entityId ? 'pointer' : 'default'};"
title="${this._formatPower(b.val)}"
@click=${() => b.entityId && this._handleClick(b.entityId)}>
<ha-icon icon="${b.icon}" class="compact-icon" style="color: ${b.iconColor};"></ha-icon>
</div>` : '')}
</div>
</div>
</ha-card>
`;
}
// --- RENDER STANDARD VIEW ---
_renderStandardView(entities) {
// FIX: Default to hidden unless explicitly set to false
const hideInactive = this.config.hide_inactive_flows !== false;
const globalFlowRate = this.config.show_flow_rates !== false;
// FLOW RATE TOGGLES
const showFlowSolar = this.config.show_flow_rate_solar !== undefined ? this.config.show_flow_rate_solar : globalFlowRate;
const showFlowGrid = this.config.show_flow_rate_grid !== undefined ? this.config.show_flow_rate_grid : globalFlowRate;
const showFlowBattery = this.config.show_flow_rate_battery !== undefined ? this.config.show_flow_rate_battery : globalFlowRate;
// LABEL TOGGLES
const showLabelSolar = this.config.show_label_solar === true;
const showLabelGrid = this.config.show_label_grid === true;
const showLabelBattery = this.config.show_label_battery === true;
const showLabelHouse = this.config.show_label_house === true;
const useColoredValues = this.config.use_colored_values === true;
const showDonut = this.config.show_donut_border === true;
const showTail = this.config.show_comet_tail === true;
const showDashedLine = this.config.show_dashed_line === true;
const showTint = this.config.show_tinted_background === true;
const hideConsumerIcons = this.config.hide_consumer_icons === true;
const showNeonGlow = this.config.show_neon_glow !== false;
// CUSTOM LABELS
const labelSolarText = this.config.solar_label || this._localize('card.label_solar');
const labelGridText = this.config.grid_label || this._localize('card.label_grid');
const labelBatteryText = this.config.battery_label || (entities.battery && this.hass.states[entities.battery] && this.hass.states[entities.battery].state > 0 ? '+' : '-') + " " + this._localize('card.label_battery');
const labelHouseText = this.config.house_label || this._localize('card.label_house');
// CUSTOM ICONS
const iconSolar = this.config.solar_icon;
const iconGrid = this.config.grid_icon;
const iconBattery = this.config.battery_icon;
// SECONDARY SENSORS (display only)
const hasSecondarySolar = !!(entities.secondary_solar && entities.secondary_solar !== "");
const hasSecondaryGrid = !!(entities.secondary_grid && entities.secondary_grid !== "");
const hasSecondaryBattery = !!(entities.secondary_battery && entities.secondary_battery !== "");
const getSecondaryVal = (entity) => {
if (!entity) return '';
const state = this.hass.states[entity];
if (!state) return '';
const val = parseFloat(state.state);
if (isNaN(val)) return state.state + (state.attributes.unit_of_measurement ? ' ' + state.attributes.unit_of_measurement : '');
const unit = state.attributes.unit_of_measurement || '';
if (unit === 'W') { return this._formatPower(val); } if (unit === 'Wh') { if (Math.abs(val) >= 1000) return (val / 1000).toFixed(2) + ' kWh'; return Math.round(val) + ' Wh'; } if (unit === 'kWh' || unit === 'kW') { return val.toFixed(1) + ' ' + unit; } if (unit.includes('EUR') || unit.includes('ct') || unit.includes('€')) { return val.toFixed(2) + ' ' + unit; } return val.toFixed(1) + (unit ? ' ' + unit : '');
};
// Determine existence of main entities
const hasSolar = !!(entities.solar && entities.solar !== "");
const hasGridCombined = !!(entities.grid_combined && entities.grid_combined !== "");
const hasGrid = !!(entities.grid && entities.grid !== "") || hasGridCombined;
const hasBattery = !!(entities.battery && entities.battery !== "");
const styleSolar = hasSolar ? '' : 'display: none;';
const styleSolarBatt = (hasSolar && hasBattery) ? '' : 'display: none;';
const styleGrid = hasGrid ? '' : 'display: none;';
const styleGridBatt = (hasGrid && hasBattery) ? '' : 'display: none;';
const styleBattery = hasBattery ? '' : 'display: none;';
const textClass = showNeonGlow ? 'flow-text' : 'flow-text no-shadow';
// Custom Labels for Consumers
const labelC1 = this.config.consumer_1_label || this._localize('card.label_car');
const labelC2 = this.config.consumer_2_label || this._localize('card.label_heater');
const labelC3 = this.config.consumer_3_label || this._localize('card.label_pool');
const getVal = (entity) => {
const state = this.hass.states[entity];
return state ? parseFloat(state.state) || 0 : 0;
};
const getValKw = (entity, isKw) => {
return getVal(entity) * (isKw ? 1000 : 1);
};
let c1Val = entities.consumer_1 ? getValKw(entities.consumer_1, this.config.consumer_1_unit_kw === true) : 0;
if (this.config.invert_consumer_1) { c1Val *= -1; }
c1Val = Math.abs(c1Val);
const c2Val = entities.consumer_2 ? getValKw(entities.consumer_2, this.config.consumer_2_unit_kw === true) : 0;
const c3Val = entities.consumer_3 ? getValKw(entities.consumer_3, this.config.consumer_3_unit_kw === true) : 0;
const alwaysShowConsumer = this.config.show_consumer_always === true;
const showC1 = (entities.consumer_1 && (alwaysShowConsumer || Math.round(c1Val) > 0));
const showC2 = (entities.consumer_2 && (alwaysShowConsumer || Math.round(c2Val) > 0));
const showC3 = (entities.consumer_3 && (alwaysShowConsumer || Math.round(c3Val) > 0));
const anyBottomVisible = showC1 || showC2 || showC3;
// Consumer 1 pipe threshold
const hideC1Pipe = this.config.consumer_1_hide_pipe === true;
const c1PipeThreshold = this.config.consumer_1_pipe_threshold || 0;
const c1PipeActive = showC1 && (!hideC1Pipe || c1Val >= c1PipeThreshold);
const solar = hasSolar ? getVal(entities.solar) : 0;
const gridCombinedVal = hasGridCombined ? getVal(entities.grid_combined) : 0;
const gridMain = hasGridCombined ? gridCombinedVal : (hasGrid ? getVal(entities.grid) : 0);
const gridExpSensor = (hasGrid && entities.grid_export) ? getVal(entities.grid_export) : 0;
let battery = hasBattery ? getVal(entities.battery) : 0;
if (this.config.invert_battery) {
battery *= -1;
}
const battSoc = (hasBattery && entities.battery_soc) ? getVal(entities.battery_soc) : 0;
const solarVal = Math.max(0, solar);
let gridImport = 0;
let gridExport = 0;
if (hasGrid) {
if (hasGridCombined) {
// COMBINED SENSOR: positive = import, negative = export
gridImport = gridCombinedVal > 0 ? gridCombinedVal : 0;
gridExport = gridCombinedVal < 0 ? Math.abs(gridCombinedVal) : 0;
} else if (entities.grid_export && entities.grid_export !== "") {
gridImport = gridMain > 0 ? gridMain : 0;
gridExport = Math.abs(gridExpSensor);
} else {
gridImport = gridMain > 0 ? gridMain : 0;
gridExport = gridMain < 0 ? Math.abs(gridMain) : 0;
}
}
// Check for separate battery charge/discharge sensors
const hasBattChargeSensor = !!(entities.battery_charge && entities.battery_charge !== "");
const hasBattDischargeSensor = !!(entities.battery_discharge && entities.battery_discharge !== "");
const batteryCharge = hasBattChargeSensor ? Math.abs(getVal(entities.battery_charge)) : (battery > 0 ? battery : 0);
const batteryDischarge = hasBattDischargeSensor ? Math.abs(getVal(entities.battery_discharge)) : (battery < 0 ? Math.abs(battery) : 0);
let solarToBatt = 0;
let gridToBatt = 0;
if (hasBattery && batteryCharge > 0) {
const hasGridToBattSensor = !!(entities.grid_to_battery && entities.grid_to_battery !== "");
if (hasGridToBattSensor) {
// Use dedicated grid-to-battery sensor
gridToBatt = Math.abs(getVal(entities.grid_to_battery));
solarToBatt = Math.max(0, batteryCharge - gridToBatt);
} else {
// Calculate: solar prioritized
if (solarVal >= batteryCharge) {
solarToBatt = batteryCharge;
gridToBatt = 0;
} else {
solarToBatt = solarVal;
gridToBatt = batteryCharge - solarVal;
}
}
}
const solarToHouse = Math.max(0, solarVal - solarToBatt - gridExport);
const gridToHouse = Math.max(0, gridImport - gridToBatt);
const house = solarToHouse + gridToHouse + batteryDischarge;
// Use house entity for display if defined, otherwise use calculated value
const houseDisplay = (entities.house && entities.house !== "") ? getVal(entities.house) : house;
const isTopArcActive = (solarToBatt > 0);
const topShift = (isTopArcActive || (!hideInactive && hasSolar && hasBattery)) ? 0 : 50;
let baseHeight = anyBottomVisible ? 480 : 340;
const contentHeight = baseHeight - topShift;
const designWidth = 420;
const availableWidth = this._cardWidth || designWidth;
let scale = availableWidth / designWidth;
const userZoom = this.config.zoom !== undefined ? this.config.zoom : 0.9;
scale = scale * userZoom;
if (scale < 0.5) scale = 0.5;
if (scale > 1.5) scale = 1.5;
const finalCardHeightPx = contentHeight * scale;
const visualWidth = 420 * scale;
const centerMarginLeft = Math.max(0, (availableWidth - visualWidth) / 2);
let houseGradientVal = '';
let houseTextCol = useColoredValues ? 'var(--neon-pink)' : '';
const tintClass = showTint ? 'tinted' : '';
const glowClass = showNeonGlow ? 'glow' : '';
let houseDominantColor = 'var(--neon-pink)';
if (house > 0) {
if (solarToHouse >= gridToHouse && solarToHouse >= batteryDischarge) {
houseDominantColor = 'var(--neon-yellow)';
} else if (gridToHouse >= solarToHouse && gridToHouse >= batteryDischarge) {
houseDominantColor = 'var(--neon-blue)';
} else if (batteryDischarge >= solarToHouse && batteryDischarge >= gridToHouse) {
houseDominantColor = 'var(--neon-green)';
}
}
if (showDonut) {
if (house > 0) {
const pctSolar = (solarToHouse / house) * 100;
const pctGrid = (gridToHouse / house) * 100;
const pctBatt = (batteryDischarge / house) * 100;
let stops = [];
let current = 0;
if (pctSolar > 0) { stops.push(`var(--neon-yellow) ${current}% ${current + pctSolar}%`); current += pctSolar; }
if (pctBatt > 0) { stops.push(`var(--neon-green) ${current}% ${current + pctBatt}%`); current += pctBatt; }
if (pctGrid > 0) { stops.push(`var(--neon-blue) ${current}% ${current + pctGrid}%`); current += pctGrid; }
if (current < 99.9) { stops.push(`var(--neon-pink) ${current}% 100%`); }
houseGradientVal = `conic-gradient(from 330deg, ${stops.join(', ')})`;
if (useColoredValues) {
const maxVal = Math.max(solarToHouse, gridToHouse, batteryDischarge);
if (maxVal > 0) {
if (maxVal === solarToHouse) houseTextCol = 'var(--neon-yellow)';
else if (maxVal === gridToHouse) houseTextCol = 'var(--neon-blue)';
else if (maxVal === batteryDischarge) houseTextCol = 'var(--neon-green)';
} else {
houseTextCol = 'var(--neon-pink)';
}
}
} else {
houseGradientVal = `var(--neon-pink)`;
houseTextCol = useColoredValues ? 'var(--neon-pink)' : '';
}
} else {
houseTextCol = useColoredValues ? 'var(--neon-pink)' : '';
}
const houseTintStyle = showTint
? `background: color-mix(in srgb, ${houseDominantColor}, transparent 85%);`
: '';
const houseGlowStyle = showNeonGlow
? `box-shadow: 0 0 15px color-mix(in srgb, ${houseDominantColor}, transparent 60%);`
: `box-shadow: none;`;
const houseBubbleStyle = `${showDonut ? `--house-gradient: ${houseGradientVal};` : ''} ${houseTintStyle} ${houseGlowStyle}`;
const isSolarActive = Math.round(solarVal) > 0;
const isGridActive = Math.round(gridImport) > 0 || Math.round(gridExport) > 0;
const isGridExporting = Math.round(gridExport) > 0 && Math.round(gridImport) === 0;
// --- Grid Donut Gradient ---
let gridGradientVal = '';
if (showDonut && hasGrid && isGridActive) {
const gridTotal = gridToHouse + gridToBatt + gridExport;
if (gridTotal > 0) {
const gPctToHouse = (gridToHouse / gridTotal) * 100;
const gPctToBatt = (gridToBatt / gridTotal) * 100;
const gPctExport = (gridExport / gridTotal) * 100;
let gStops = [];
let gCurrent = 0;
if (gPctToHouse > 0) { gStops.push(`var(--neon-blue) ${gCurrent}% ${gCurrent + gPctToHouse}%`); gCurrent += gPctToHouse; }
if (gPctToBatt > 0) { gStops.push(`var(--neon-green) ${gCurrent}% ${gCurrent + gPctToBatt}%`); gCurrent += gPctToBatt; }
if (gPctExport > 0) { gStops.push(`var(--export-color) ${gCurrent}% ${gCurrent + gPctExport}%`); gCurrent += gPctExport; }
if (gCurrent < 99.9) { gStops.push(`var(--neon-blue) ${gCurrent}% 100%`); }
gridGradientVal = `conic-gradient(from 330deg, ${gStops.join(', ')})`;
} else {
gridGradientVal = isGridExporting ? 'var(--export-color)' : 'var(--neon-blue)';
}
}
const solarColor = isSolarActive ? 'var(--icon-solar-color)' : 'var(--secondary-text-color)';
const gridColor = isGridExporting ? 'var(--export-color)' : (isGridActive ? 'var(--neon-blue)' : 'var(--secondary-text-color)');
const gridIconColor = (isGridActive && this.config.color_icon_grid) ? 'var(--icon-grid-color)' : gridColor;
const gridTextColor = (isGridActive && this.config.color_text_grid) ? 'var(--text-grid-color)' : gridColor;
const getAnimStyle = (val) => {
if (val <= 1) return "opacity: 0;";
// --- Dynamic speed based on power ---
// Higher power = faster animation (shorter duration)
// Range: 2s (very fast, ~5000W+) to 12s (slow, ~50W)
const minDuration = 4;
const maxDuration = 12;
const factor = 12000;
let duration = factor / val;
duration = Math.max(minDuration, Math.min(maxDuration, duration));
// --- Dynamic particle density based on power ---
// Higher power = more/denser particles (shorter gap)
// Lower power = fewer/sparse particles (longer gap)
let dashSize, gapSize;
if (showTail) {
// Comet tail: vary tail length with power
dashSize = Math.round(15 + (val / 200) * 25); // 15-40
dashSize = Math.min(dashSize, 40);
gapSize = Math.round(380 - (val / 200) * 200); // 380-180
gapSize = Math.max(gapSize, 180);
} else if (showDashedLine) {
// Dashed line: vary dash density
dashSize = Math.round(8 + (val / 500) * 10); // 8-18
dashSize = Math.min(dashSize, 18);
gapSize = Math.round(18 - (val / 1000) * 10); // 18-8
gapSize = Math.max(gapSize, 8);
duration = duration * 5; // Dashed lines are slower
} else {
// Default dots: vary dot count/density
dashSize = 0; // stays as dots
gapSize = Math.round(380 - (val / 200) * 250); // 380-130
gapSize = Math.max(gapSize, 130);
}
const dynamicDash = `${dashSize} ${gapSize}`;
return `opacity: 1; animation-duration: ${duration}s; stroke-dasharray: ${dynamicDash};`;
};
const getPipeStyle = (val) => {
if (!hideInactive) return "opacity: 0.2;";
return val > 1 ? "opacity: 0.2;" : "opacity: 0;";
};
const getTextStyle = (val, type) => {
let isVisible = false;
if (type === 'solar') isVisible = showFlowSolar;
else if (type === 'grid') isVisible = showFlowGrid;
else if (type === 'battery') isVisible = showFlowBattery;
if (!isVisible) return "display: none;";
return val > 5 ? "opacity: 1;" : "opacity: 0;";
};
const getColorStyle = (colorVar) => {
return useColoredValues ? `color: var(${colorVar});` : '';
};
const getConsumerColorStyle = (hex) => {
return useColoredValues ? `color: ${hex};` : '';
}
const renderLabel = (text, isVisible) => {
if (!isVisible) return html``;
return html`<div class="sub">${text}</div>`;
};
const renderSecondaryOrLabel = (labelText, showLabel, secondaryEntity, hasSecondary) => {
if (hasSecondary) {
const secVal = getSecondaryVal(secondaryEntity);
return html`<div class="sub secondary-val">${secVal}</div>`;
}
return renderLabel(labelText, showLabel);
};
const renderMainIcon = (type, val, customIcon, color = null) => {
if (customIcon) {
const style = color ? `color: ${color};` : (type === 'solar' ? 'color: var(--icon-solar-color);' : (type === 'grid' ? 'color: var(--icon-grid-color);' : (type === 'battery' ? 'color: var(--icon-battery-color);' : (type === 'house' ? 'color: var(--icon-house-color);' : ''))));
return html`<ha-icon icon="${customIcon}" class="icon-custom" style="${style}"></ha-icon>`;
}
return this._renderIcon(type, val, color);
};
const renderConsumer = (isVisible, cssClass, configKey, label, iconType, val, hexColor) => {
if (!isVisible) return html``;
const customIcon = this.config[`${configKey}_icon`];
let iconContent;
const iconColorVar = `var(--icon-${configKey.replace(/_/g, '-')}-color)`;
if (hideConsumerIcons) {
iconContent = html``;
} else if (customIcon) {
iconContent = html`<ha-icon icon="${customIcon}" class="icon-custom" style="color: ${iconColorVar};"></ha-icon>`;
} else {
iconContent = this._renderIcon(iconType, val);
}
const secEntity = entities[`secondary_${configKey}`];
const hasSecondary = !!(secEntity && secEntity !== "");
const textStyle = this.config[`color_text_${configKey}`]
? `color: var(--text-${configKey.replace(/_/g, '-')}-color);`
: getConsumerColorStyle(hexColor);
return html`
<div class="bubble ${cssClass} ${cssClass.replace('c', 'node-c')} ${tintClass} ${glowClass}"
@click=${() => this._handleClick(entities[configKey])}>
${iconContent}
${renderSecondaryOrLabel(label, true, secEntity, hasSecondary)}
<div class="value" style="${textStyle}">${this._formatPower(val)}</div>
</div>
`;
};
const getConsumerPipeStyle = (isActive, val) => {
if (!isActive) return "display: none;";
return getPipeStyle(val);
};
const getConsumerAnimStyle = (isActive, val) => {
if (!isActive) return "display: none;";
return getAnimStyle(val);
};
const pathSolarHouse = "M 50 160 Q 50 265 165 265";
const pathSolarBatt = "M 50 70 Q 210 -20 370 70";
const pathGridImport = "M 210 160 L 210 220";
const pathGridExport = "M 95 115 Q 130 145 165 115";
const pathHouseExport = "M 210 220 L 210 160";
const exportFromSolar = solarVal > 1;
const activeExportPath = exportFromSolar ? pathGridExport : pathHouseExport;
const exportTextX = exportFromSolar ? '130' : '185';
const exportTextY = exportFromSolar ? '145' : '195';
const pathGridToBatt = "M 255 115 Q 290 145 325 115";
const pathBattHouse = "M 370 160 Q 370 265 255 265";
const pathHouseC1 = "M 165 265 Q 50 265 50 370";
const pathHouseC2 = "M 210 310 L 210 370";
const pathHouseC3 = "M 255 265 Q 370 265 370 370";
const houseTextStyle = this.config.color_text_house
? 'color: var(--text-house-color);'
: (houseTextCol ? `color: ${houseTextCol};` : '');
const dashArrayVal = showTail ? '30 360' : (showDashedLine ? '13 13' : '0 380');
const strokeWidthVal = showDashedLine ? 4 : 8;
return html`
<ha-card style="height: ${finalCardHeightPx}px; --flow-dasharray: ${dashArrayVal}; --flow-stroke-width: ${strokeWidthVal}px;">
<div class="scale-wrapper" style="transform: scale(${scale}); margin-left: ${centerMarginLeft}px;">
<div class="absolute-container" style="height: ${baseHeight}px; top: -${topShift}px;">
<svg height="${baseHeight}" viewBox="0 0 420 ${baseHeight}" preserveAspectRatio="xMidYMid meet">
<path class="bg-path bg-solar" d="${pathSolarHouse}" style="${getPipeStyle(solarToHouse)} ${styleSolar}" />
<path class="bg-path bg-solar" d="${pathSolarBatt}" style="${getPipeStyle(solarToBatt)} ${styleSolarBatt}" />
<path class="bg-path bg-grid" d="${pathGridImport}" style="${getPipeStyle(gridToHouse)} ${styleGrid}" />
<path class="bg-path bg-export" d="${activeExportPath}" style="${getPipeStyle(gridExport)} ${styleGrid}" />
<path class="bg-path bg-grid" d="${pathGridToBatt}" style="${getPipeStyle(gridToBatt)} ${styleGridBatt}" />
<path class="bg-path bg-battery" d="${pathBattHouse}" style="${getPipeStyle(batteryDischarge)} ${styleBattery}" />
<path d="${pathHouseC1}" fill="none" stroke="${this._getConsumerPipeColor(1)}" stroke-width="6" style="${getConsumerPipeStyle(c1PipeActive, c1Val)}" />
<path d="${pathHouseC2}" fill="none" stroke="${this._getConsumerPipeColor(2)}" stroke-width="6" style="${getConsumerPipeStyle(showC2, c2Val)}" />
<path d="${pathHouseC3}" fill="none" stroke="${this._getConsumerPipeColor(3)}" stroke-width="6" style="${getConsumerPipeStyle(showC3, c3Val)}" />
<path class="flow-line flow-solar" d="${pathSolarHouse}" style="${getAnimStyle(solarToHouse)} ${styleSolar}" />
<path class="flow-line flow-solar" d="${pathSolarBatt}" style="${getAnimStyle(solarToBatt)} ${styleSolarBatt}" />
<path class="flow-line flow-grid" d="${pathGridImport}" style="${getAnimStyle(gridToHouse)} ${styleGrid}" />
<path class="flow-line flow-export" d="${activeExportPath}" style="${getAnimStyle(gridExport)} ${styleGrid}" />
<path class="flow-line flow-grid" d="${pathGridToBatt}" style="${getAnimStyle(gridToBatt)} ${styleGridBatt}" />
<path class="flow-line flow-battery" d="${pathBattHouse}" style="${getAnimStyle(batteryDischarge)} ${styleBattery}" />
<path class="flow-line" d="${pathHouseC1}" stroke="${this._getConsumerPipeColor(1)}" style="${getConsumerAnimStyle(c1PipeActive, c1Val)}" />
<path class="flow-line" d="${pathHouseC2}" stroke="${this._getConsumerPipeColor(2)}" style="${getConsumerAnimStyle(showC2, c2Val)}" />
<path class="flow-line" d="${pathHouseC3}" stroke="${this._getConsumerPipeColor(3)}" style="${getConsumerAnimStyle(showC3, c3Val)}" />
<text x="100" y="235" class="${textClass} text-solar" style="${getTextStyle(solarToHouse, 'solar')} ${styleSolar}">${this._formatPower(solarToHouse)}</text>
<text x="210" y="45" class="${textClass} text-solar" style="${getTextStyle(solarToBatt, 'solar')} ${styleSolarBatt}">${this._formatPower(solarToBatt)}</text>
<text x="235" y="195" class="${textClass} text-grid" style="${getTextStyle(gridToHouse, 'grid')} ${styleGrid}">${this._formatPower(gridToHouse)}</text>
<text x="${exportTextX}" y="${exportTextY}" class="${textClass} text-export" style="${getTextStyle(gridExport, 'grid')} ${styleGrid}">${this._formatPower(gridExport)}</text>
<text x="290" y="145" class="${textClass} text-grid" style="${getTextStyle(gridToBatt, 'grid')} ${styleGridBatt}">${this._formatPower(gridToBatt)}</text>
<text x="320" y="235" class="${textClass} text-battery" style="${getTextStyle(batteryDischarge, 'battery')} ${styleBattery}">${this._formatPower(batteryDischarge)}</text>
</svg>
${hasSolar ? html`
<div class="bubble ${isSolarActive ? 'solar' : 'inactive'} node-solar ${tintClass} ${isSolarActive ? glowClass : ''}"
@click=${() => this._handleClick(entities.solar)}>
${renderMainIcon('solar', solarVal, iconSolar, solarColor)}
${renderSecondaryOrLabel(labelSolarText, showLabelSolar, entities.secondary_solar, hasSecondarySolar)}
<div class="value" style="${isSolarActive ? (this.config.color_text_solar ? 'color: var(--text-solar-color);' : getColorStyle('--neon-yellow')) : `color: ${solarColor};`}">${this._formatPower(solarVal)}</div>
</div>` : ''}
${hasGrid ? html`
<div class="bubble ${isGridActive ? (isGridExporting ? 'grid exporting' : 'grid') : 'inactive'} node-grid ${showDonut && isGridActive ? 'donut' : ''} ${tintClass} ${isGridActive ? glowClass : ''}"
style="${showDonut && isGridActive ? `--grid-gradient: ${gridGradientVal};` : ''}"
@click=${() => this._handleClick(entities.grid_combined || entities.grid)}>
${renderMainIcon('grid', isGridExporting ? gridExport : gridImport, iconGrid, gridIconColor)}
${renderSecondaryOrLabel(labelGridText, showLabelGrid, entities.secondary_grid, hasSecondaryGrid)}
<div class="value" style="color: ${gridTextColor};">
${isGridExporting ? html`<span class="direction-arrow">&#9650;</span>` : (isGridActive ? html`<span class="direction-arrow">&#9660;</span>` : '')}
${this._formatPower(isGridExporting ? gridExport : gridImport)}
</div>
</div>` : ''}
${hasBattery ? html`
<div class="bubble battery node-battery ${tintClass} ${glowClass}"
@click=${() => this._handleClick(entities.battery)}>
${renderMainIcon('battery', battSoc, iconBattery)}
${renderSecondaryOrLabel(labelBatteryText, showLabelBattery, entities.secondary_battery, hasSecondaryBattery)}
<div class="value" style="${this.config.color_text_battery ? 'color: var(--text-battery-color);' : getColorStyle('--neon-green')}">${Math.round(battSoc)}%</div>
</div>` : ''}
<div class="bubble house node-house ${showDonut ? 'donut' : ''} ${tintClass}"
style="${houseBubbleStyle}"
@click=${() => this._handleClick(entities.house)}>
${renderMainIcon('house', 0, null, this.config.color_icon_house ? 'var(--icon-house-color)' : houseDominantColor)}
${renderLabel(labelHouseText, showLabelHouse)}
<div class="value" style="${houseTextStyle}">${this._formatPower(houseDisplay)}</div>
</div>
${renderConsumer(showC1, 'c1', 'consumer_1', labelC1, 'car', c1Val, this._getConsumerColor(1))}
${renderConsumer(showC2, 'c2', 'consumer_2', labelC2, 'heater', c2Val, this._getConsumerColor(2))}
${renderConsumer(showC3, 'c3', 'consumer_3', labelC3, 'pool', c3Val, this._getConsumerColor(3))}
</div>
</div>
</ha-card>
`;
}
render() {
if (!this.config || !this.hass) return html``;
// SWITCH VIEW BASED ON CONFIG
if (this.config.compact_view === true) {
return this._renderCompactView(this.config.entities || {});
} else {
return this._renderStandardView(this.config.entities || {});
}
}
}
customElements.define("power-flux-card", PowerFluxCard);
})(lang_en, lang_de);
window.customCards = window.customCards || [];
window.customCards.push({
type: "power-flux-card",
name: "Power Flux Card",
description: "Advanced Animated Energy Flow Card",
});