This commit is contained in:
jayjojayson 2026-02-24 00:29:01 +01:00
parent 7f6e44e938
commit e2f070941d
6 changed files with 471 additions and 164 deletions

View file

@ -26,9 +26,19 @@ If you like the Card, I would appreciate a Star rating ⭐ from you. 🤗
- **Donut Chart**: Optional donut chart around the house icon showing energy mix. - **Donut Chart**: Optional donut chart around the house icon showing energy mix.
- **Comet Tail / Dashed Lines**: Choose your preferred animation style. - **Comet Tail / Dashed Lines**: Choose your preferred animation style.
- **Zoom**: Adjustable scale to fit your dashboard. - **Zoom**: Adjustable scale to fit your dashboard.
- **Custom Colors**: Define custom colors for each source and consumer via the editor.
- **Background Color**: Enable a slightly tinted background for the circles in the default view.
- **More Info**: Click on any source/consumer for detailed information in a more-info dialog.
- **Grid Import/Export**: Supports both separate Import/Export entities or a combined entity with positive/negative values.
- **Grid-to-Battery**: Optional direct sensor for Grid-to-Battery flow, bypassing the standard calculation.
- **Secondary Sensors**: Optionally display a secondary sensor value in the main circles (e.g., daily yield for Solar, current charge/discharge power for Battery) and consumer bubbles.
- **Localization**: Fully translated in English and German. - **Localization**: Fully translated in English and German.
- **Visual Editor**: easy configuration via the Home Assistant UI. - **Visual Editor**: easy configuration via the Home Assistant UI.
[![Support](https://img.shields.io/badge/Features-Video%20german-steelblue?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=HGFBJJRWGW0)
---
### 🚀 Installation ### 🚀 Installation
### HACS (Recommended) ### HACS (Recommended)
@ -54,6 +64,8 @@ If you like the Card, I would appreciate a Star rating ⭐ from you. 🤗
- URL: `/local/community/power-flux-card/power-flux-card.js` - URL: `/local/community/power-flux-card/power-flux-card.js`
- Type: JavaScript Module - Type: JavaScript Module
---
### ⚙️ Configuration ### ⚙️ Configuration
You can configure the card directly via the visual editor in Home Assistant. You can configure the card directly via the visual editor in Home Assistant.

View file

@ -26,9 +26,21 @@ Wenn euch die custom Card gefällt, würde ich mich sehr über eine Sternebewert
- **Donut Chart**: Optionales Donut-Diagramm um das Haus-Icon, das den Energiemix zeigt. - **Donut Chart**: Optionales Donut-Diagramm um das Haus-Icon, das den Energiemix zeigt.
- **Kometenschweif / Gestrichelte Linien**: Wählen Sie Ihren bevorzugten Animationsstil. - **Kometenschweif / Gestrichelte Linien**: Wählen Sie Ihren bevorzugten Animationsstil.
- **Zoom**: Anpassbare Größe für Ihr Dashboard. - **Zoom**: Anpassbare Größe für Ihr Dashboard.
- **Benutzerdefinierte Farben**: Definiere benutzerdefinierte Farben für jede Quelle und jeden Verbraucher über den Editor.
- **Hintergrundfarbe**: Aktiviere einen leicht getönten Hintergrund für die Kreise in der Standard-Ansicht.
- **Dynamische Animationsgeschwindigkeit**: Partikelgeschwindigkeit und -dichte passen sich dem aktuellen Energiefluss an.
- **Weitere Informationen**: Klicke auf eine beliebige Quelle/Verbraucher, um detaillierte Informationen in einem More-Info-Dialog anzuzeigen.
- **Netz-Import/Export**: Unterstützt sowohl separate Import/Export-Entitäten als auch eine kombinierte Entität mit positiven/negativen Werten.
- **Netz-zu-Batterie**: Optionaler direkter Sensor für den Netz-zu-Batterie-Fluss, der die Standardberechnung umgeht.
- **Sekundäre Sensoren**: Optional können sekundäre Sensorwerte in den Hauptkreisen (z.B. Tagesertrag für Solar, aktuelle Lade-/Entladeleistung für Batterie) angezeigt werden.
- **Lokalisierung**: Vollständig übersetzt in Deutsch und Englisch. - **Lokalisierung**: Vollständig übersetzt in Deutsch und Englisch.
- **Visueller Editor**: Einfache Konfiguration über die Home Assistant UI. - **Visueller Editor**: Einfache Konfiguration über die Home Assistant UI.
[![Watch the video](https://img.youtube.com/vi/HGFBJJRWGW0/0.jpg)](https://www.youtube.com/watch?v=HGFBJJRWGW0
)
---
### 🚀 Installation ### 🚀 Installation
### HACS (Empfohlen) ### HACS (Empfohlen)
@ -53,6 +65,9 @@ Wenn euch die custom Card gefällt, würde ich mich sehr über eine Sternebewert
- URL: `/local/community/power-flux-card/power-flux-card.js` - URL: `/local/community/power-flux-card/power-flux-card.js`
- Typ: JavaScript Module - Typ: JavaScript Module
---
### ⚙️ Konfiguration ### ⚙️ Konfiguration
Du kannst die Karte direkt über den visuellen Editor in Home Assistant konfigurieren. Du kannst die Karte direkt über den visuellen Editor in Home Assistant konfigurieren.

View file

@ -29,8 +29,16 @@ export default {
"editor.donut_chart": "Donut Chart (Grid/Haus)", "editor.donut_chart": "Donut Chart (Grid/Haus)",
"editor.comet_tail": "Comet Tail Effect", "editor.comet_tail": "Comet Tail Effect",
"editor.dashed_line": "Dashed Line Effect", "editor.dashed_line": "Dashed Line Effect",
"editor.tinted_background": "Farbiger Hintergrund in Kreisen",
"editor.colored_values": "Farbige Textwerte", "editor.colored_values": "Farbige Textwerte",
"editor.hide_consumer_icons": "Icons unten ausblenden", "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 (Watt)",
"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 die getrennten Import/Export Sensoren.",
"editor.color_picker": "Farbe anpassen",
}, },
card: { card: {
"card.label_solar": "Solar", "card.label_solar": "Solar",

View file

@ -29,8 +29,16 @@ export default {
"editor.donut_chart": "Donut Chart (Grid/House)", "editor.donut_chart": "Donut Chart (Grid/House)",
"editor.comet_tail": "Comet Tail Effect", "editor.comet_tail": "Comet Tail Effect",
"editor.dashed_line": "Dashed Line Effect", "editor.dashed_line": "Dashed Line Effect",
"editor.tinted_background": "Tinted Background in Bubbles",
"editor.colored_values": "Colored Text Values", "editor.colored_values": "Colored Text Values",
"editor.hide_consumer_icons": "Hide Consumer Icons", "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 (Watt)",
"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 separate import/export sensors.",
"editor.color_picker": "Custom Color",
}, },
card: { card: {
"card.label_solar": "Solar", "card.label_solar": "Solar",

View file

@ -65,10 +65,12 @@ class PowerFluxCardEditor extends LitElement {
if (key) { if (key) {
const entityKeys = [ const entityKeys = [
'solar', 'grid', 'grid_export', 'solar', 'grid', 'grid_export', 'grid_combined',
'battery', 'battery_soc', 'battery', 'battery_soc', 'grid_to_battery',
'house', 'house',
'consumer_1', 'consumer_2', 'consumer_3' '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 }; let newConfig = { ...this._config };
@ -101,6 +103,65 @@ class PowerFluxCardEditor extends LitElement {
this._subView = null; 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>
`;
}
static get styles() { static get styles() {
return css` return css`
.card-config { .card-config {
@ -179,6 +240,60 @@ class PowerFluxCardEditor extends LitElement {
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
margin: 10px 0; 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: 36px;
height: 36px;
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);
}
`; `;
} }
@ -193,14 +308,7 @@ class PowerFluxCardEditor extends LitElement {
<h2>${this._localize('editor.solar_section')}</h2> <h2>${this._localize('editor.solar_section')}</h2>
</div> </div>
<ha-selector ${this._renderEntitySelector(entitySelectorSchema, entities.solar, 'solar', this._localize('editor.entity'))}
.hass=${this.hass}
.selector=${entitySelectorSchema}
.value=${entities.solar}
.configValue=${'solar'}
.label=${this._localize('editor.entity')}
@value-changed=${this._valueChanged}
></ha-selector>
<div class="separator"></div> <div class="separator"></div>
@ -222,6 +330,10 @@ class PowerFluxCardEditor extends LitElement {
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-selector> ></ha-selector>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_solar || "", 'secondary_solar', this._localize('editor.secondary_sensor'))}
${this._renderColorPicker('color_solar', this._localize('editor.color_picker'), '#ffdd00')}
<div class="separator"></div> <div class="separator"></div>
<div class="switch-row"> <div class="switch-row">
@ -253,23 +365,16 @@ class PowerFluxCardEditor extends LitElement {
<h2>${this._localize('editor.grid_section')}</h2> <h2>${this._localize('editor.grid_section')}</h2>
</div> </div>
<ha-selector ${this._renderEntitySelector(entitySelectorSchema, entities.grid_combined || "", 'grid_combined', this._localize('editor.grid_combined_sensor'))}
.hass=${this.hass} <div style="font-size: 0.8em; color: var(--secondary-text-color); margin-top: 4px;">
.selector=${entitySelectorSchema} ${this._localize('editor.grid_combined_hint')}
.value=${entities.grid} </div>
.configValue=${'grid'}
.label=${this._localize('card.label_import') + " (W)"}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector <div class="separator"></div>
.hass=${this.hass}
.selector=${entitySelectorSchema} ${this._renderEntitySelector(entitySelectorSchema, entities.grid, 'grid', this._localize('card.label_import') + " (W)")}
.value=${entities.grid_export}
.configValue=${'grid_export'} ${this._renderEntitySelector(entitySelectorSchema, entities.grid_export, 'grid_export', this._localize('card.label_export') + " (W, Optional)")}
.label=${this._localize('card.label_export') + " (W, Optional)"}
@value-changed=${this._valueChanged}
></ha-selector>
<div class="separator"></div> <div class="separator"></div>
@ -291,6 +396,10 @@ class PowerFluxCardEditor extends LitElement {
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-selector> ></ha-selector>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_grid || "", 'secondary_grid', this._localize('editor.secondary_sensor'))}
${this._renderColorPicker('color_grid', this._localize('editor.color_picker'), '#3b82f6')}
<div class="separator"></div> <div class="separator"></div>
<div class="switch-row"> <div class="switch-row">
@ -322,23 +431,14 @@ class PowerFluxCardEditor extends LitElement {
<h2>${this._localize('editor.battery_section')}</h2> <h2>${this._localize('editor.battery_section')}</h2>
</div> </div>
<ha-selector ${this._renderEntitySelector(entitySelectorSchema, entities.battery, 'battery', this._localize('editor.entity'))}
.hass=${this.hass}
.selector=${entitySelectorSchema}
.value=${entities.battery}
.configValue=${'battery'}
.label=${this._localize('editor.entity')}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector ${this._renderEntitySelector(entitySelectorSchema, entities.battery_soc, 'battery_soc', this._localize('editor.battery_soc_label'))}
.hass=${this.hass}
.selector=${entitySelectorSchema} ${this._renderEntitySelector(entitySelectorSchema, entities.grid_to_battery || "", 'grid_to_battery', this._localize('editor.grid_to_battery_sensor'))}
.value=${entities.battery_soc} <div style="font-size: 0.8em; color: var(--secondary-text-color); margin-top: 4px;">
.configValue=${'battery_soc'} ${this._localize('editor.grid_to_battery_hint')}
.label=${this._localize('editor.battery_soc_label')} </div>
@value-changed=${this._valueChanged}
></ha-selector>
<div class="separator"></div> <div class="separator"></div>
@ -360,6 +460,10 @@ class PowerFluxCardEditor extends LitElement {
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-selector> ></ha-selector>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_battery || "", 'secondary_battery', this._localize('editor.secondary_sensor'))}
${this._renderColorPicker('color_battery', this._localize('editor.color_picker'), '#00ff88')}
<div class="separator"></div> <div class="separator"></div>
<div class="switch-row"> <div class="switch-row">
@ -402,14 +506,7 @@ class PowerFluxCardEditor extends LitElement {
<div class="consumer-group"> <div class="consumer-group">
<div class="consumer-title">${this._localize('editor.house_total_title')}</div> <div class="consumer-title">${this._localize('editor.house_total_title')}</div>
<ha-selector ${this._renderEntitySelector(entitySelectorSchema, entities.house || "", 'house', this._localize('editor.house_sensor_label'))}
.hass=${this.hass}
.selector=${entitySelectorSchema}
.value=${entities.house || ""}
.configValue=${'house'}
.label=${this._localize('editor.house_sensor_label')}
@value-changed=${this._valueChanged}
></ha-selector>
<div style="font-size: 0.8em; color: var(--secondary-text-color); margin-top: 4px;"> <div style="font-size: 0.8em; color: var(--secondary-text-color); margin-top: 4px;">
${this._localize('editor.house_sensor_hint')} ${this._localize('editor.house_sensor_hint')}
</div> </div>
@ -417,14 +514,7 @@ class PowerFluxCardEditor extends LitElement {
<div class="consumer-group"> <div class="consumer-group">
<div class="consumer-title" style="color: #a855f7;">${this._localize('editor.consumer_1_title')}</div> <div class="consumer-title" style="color: #a855f7;">${this._localize('editor.consumer_1_title')}</div>
<ha-selector ${this._renderEntitySelector(entitySelectorSchema, entities.consumer_1, 'consumer_1', this._localize('editor.entity'))}
.hass=${this.hass}
.selector=${entitySelectorSchema}
.value=${entities.consumer_1}
.configValue=${'consumer_1'}
.label=${this._localize('editor.entity')}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector <ha-selector
.hass=${this.hass} .hass=${this.hass}
@ -443,18 +533,24 @@ class PowerFluxCardEditor extends LitElement {
.label=${this._localize('editor.icon')} .label=${this._localize('editor.icon')}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-selector> ></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>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_consumer_1 || "", 'secondary_consumer_1', this._localize('editor.secondary_sensor'))}
${this._renderColorPicker('color_consumer_1', this._localize('editor.color_picker'), '#a855f7')}
</div> </div>
<div class="consumer-group"> <div class="consumer-group">
<div class="consumer-title" style="color: #f97316;">${this._localize('editor.consumer_2_title')}</div> <div class="consumer-title" style="color: #f97316;">${this._localize('editor.consumer_2_title')}</div>
<ha-selector ${this._renderEntitySelector(entitySelectorSchema, entities.consumer_2, 'consumer_2', this._localize('editor.entity'))}
.hass=${this.hass}
.selector=${entitySelectorSchema}
.value=${entities.consumer_2}
.configValue=${'consumer_2'}
.label=${this._localize('editor.entity')}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector <ha-selector
.hass=${this.hass} .hass=${this.hass}
@ -473,18 +569,15 @@ class PowerFluxCardEditor extends LitElement {
.label=${this._localize('editor.icon')} .label=${this._localize('editor.icon')}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-selector> ></ha-selector>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_consumer_2 || "", 'secondary_consumer_2', this._localize('editor.secondary_sensor'))}
${this._renderColorPicker('color_consumer_2', this._localize('editor.color_picker'), '#f97316')}
</div> </div>
<div class="consumer-group"> <div class="consumer-group">
<div class="consumer-title" style="color: #06b6d4;">${this._localize('editor.consumer_3_title')}</div> <div class="consumer-title" style="color: #06b6d4;">${this._localize('editor.consumer_3_title')}</div>
<ha-selector ${this._renderEntitySelector(entitySelectorSchema, entities.consumer_3, 'consumer_3', this._localize('editor.entity'))}
.hass=${this.hass}
.selector=${entitySelectorSchema}
.value=${entities.consumer_3}
.configValue=${'consumer_3'}
.label=${this._localize('editor.entity')}
@value-changed=${this._valueChanged}
></ha-selector>
<ha-selector <ha-selector
.hass=${this.hass} .hass=${this.hass}
@ -503,6 +596,10 @@ class PowerFluxCardEditor extends LitElement {
.label=${this._localize('editor.icon')} .label=${this._localize('editor.icon')}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-selector> ></ha-selector>
${this._renderEntitySelector(entitySelectorSchema, entities.secondary_consumer_3 || "", 'secondary_consumer_3', this._localize('editor.secondary_sensor'))}
${this._renderColorPicker('color_consumer_3', this._localize('editor.color_picker'), '#06b6d4')}
</div> </div>
`; `;
} }
@ -600,6 +697,15 @@ class PowerFluxCardEditor extends LitElement {
<div class="switch-label">${this._localize('editor.dashed_line')}</div> <div class="switch-label">${this._localize('editor.dashed_line')}</div>
</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"> <div class="switch-row">
<ha-switch <ha-switch
.checked=${this._config.use_colored_values === true} .checked=${this._config.use_colored_values === true}

View file

@ -59,6 +59,7 @@ console.log(
solar: "", solar: "",
grid: "", grid: "",
grid_export: "", grid_export: "",
grid_combined: "",
battery: "", battery: "",
battery_soc: "", battery_soc: "",
house: "", house: "",
@ -97,6 +98,36 @@ console.log(
this._resizeObserver.observe(this); 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_consumer_1': '--consumer-1-color',
'color_consumer_2': '--consumer-2-color',
'color_consumer_3': '--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() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
if (this._resizeObserver) { if (this._resizeObserver) {
@ -115,8 +146,23 @@ console.log(
--neon-red: #ff3333; --neon-red: #ff3333;
--grid-grey: #9e9e9e; --grid-grey: #9e9e9e;
--export-purple: #a855f7; --export-purple: #a855f7;
--consumer-1-color: #a855f7;
--consumer-2-color: #f97316;
--consumer-3-color: #06b6d4;
--flow-dasharray: 0 380; --flow-dasharray: 0 380;
} }
:host([data-theme-light]) {
--neon-yellow: #c8a800;
--neon-blue: #2563eb;
--neon-green: #059669;
--neon-pink: #db2777;
--neon-red: #dc2626;
--grid-grey: #6b7280;
--export-purple: #7c3aed;
--consumer-1-color: #7c3aed;
--consumer-2-color: #ea580c;
--consumer-3-color: #0891b2;
}
ha-card { ha-card {
padding: 0; padding: 0;
position: relative; position: relative;
@ -170,7 +216,7 @@ console.log(
.compact-bar-wrapper { .compact-bar-wrapper {
height: 36px; height: 36px;
width: 100%; width: 100%;
background: #333; background: var(--card-background-color, #333);
border-radius: 5px; border-radius: 5px;
margin: 4px 0; margin: 4px 0;
display: flex; display: flex;
@ -192,16 +238,15 @@ console.log(
} }
/* Source Colors */ /* Source Colors */
.src-solar { background: var(--neon-green); color: black; } .src-solar { background: var(--neon-yellow); color: black; }
.src-grid { background: var(--grid-grey); color: black; } .src-grid { background: var(--neon-blue); color: black; }
.src-battery { background: var(--neon-yellow); color: black; } .src-battery { background: var(--neon-green); color: black; }
/* --- STANDARD VIEW STYLES --- */ /* --- STANDARD VIEW STYLES --- */
.scale-wrapper { .scale-wrapper {
width: 420px; width: 420px;
transform-origin: top center; transform-origin: top left;
transition: transform 0.1s linear; transition: transform 0.1s linear;
margin: 0 auto;
} }
.absolute-container { .absolute-container {
@ -221,15 +266,18 @@ console.log(
z-index: 2; z-index: 2;
transition: all 0.3s ease; transition: all 0.3s ease;
box-sizing: border-box; box-sizing: border-box;
cursor: pointer;
} }
.bubble.tinted { background: rgba(255, 255, 255, 0.05); } .bubble.tinted { background: rgba(255, 255, 255, 0.05); }
.bubble.tinted.solar { background: color-mix(in srgb, var(--neon-yellow), transparent 85%); } .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 { background: color-mix(in srgb, var(--neon-blue), transparent 85%); }
.bubble.tinted.grid.exporting { background: color-mix(in srgb, var(--neon-red), transparent 85%); }
.bubble.grid.exporting { border-color: var(--neon-red); }
.bubble.tinted.battery { background: color-mix(in srgb, var(--neon-green), transparent 85%); } .bubble.tinted.battery { background: color-mix(in srgb, var(--neon-green), transparent 85%); }
.bubble.tinted.c1 { background: color-mix(in srgb, #a855f7, transparent 85%); } .bubble.tinted.c1 { background: color-mix(in srgb, var(--consumer-1-color), transparent 85%); }
.bubble.tinted.c2 { background: color-mix(in srgb, #f97316, transparent 85%); } .bubble.tinted.c2 { background: color-mix(in srgb, var(--consumer-2-color), transparent 85%); }
.bubble.tinted.c3 { background: color-mix(in srgb, #06b6d4, 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 { border-color: var(--neon-pink); }
.bubble.house.tinted { background: color-mix(in srgb, var(--neon-pink), transparent 85%); } .bubble.house.tinted { background: color-mix(in srgb, var(--neon-pink), transparent 85%); }
@ -243,19 +291,23 @@ console.log(
} }
.icon-svg, .icon-custom { .icon-svg, .icon-custom {
width: 34px; height: 34px; position: absolute; top: 13px; left: 50%; margin-left: -17px; z-index: 2; display: block; width: 33px; height: 33px; position: absolute; top: 10px; left: 50%; margin-left: -17px; z-index: 2; display: block;
} }
.icon-custom { --mdc-icon-size: 34px; } .icon-custom { --mdc-icon-size: 34px; }
.sub { .sub {
font-size: 9px; color: var(--secondary-text-color); text-transform: uppercase; letter-spacing: 0.5px; 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: 48px; left: 0; width: 100%; text-align: center; margin: 0; pointer-events: none; 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 { .value {
font-weight: bold; font-size: 15px; white-space: nowrap; z-index: 2; transition: color 0.3s ease; 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; 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); } } @keyframes spin { 100% { transform: rotate(360deg); } }
.spin-slow { animation: spin 12s linear infinite; transform-origin: center; } .spin-slow { animation: spin 12s linear infinite; transform-origin: center; }
@ -269,17 +321,17 @@ console.log(
.solar { border-color: var(--neon-yellow); } .solar { border-color: var(--neon-yellow); }
.battery { border-color: var(--neon-green); } .battery { border-color: var(--neon-green); }
.grid { border-color: var(--neon-blue); } .grid { border-color: var(--neon-blue); }
.c1 { border-color: #a855f7; } .c1 { border-color: var(--consumer-1-color); }
.c2 { border-color: #f97316; } .c2 { border-color: var(--consumer-2-color); }
.c3 { border-color: #06b6d4; } .c3 { border-color: var(--consumer-3-color); }
.inactive { border-color: var(--secondary-text-color); } .inactive { border-color: var(--secondary-text-color); }
.glow.solar { box-shadow: 0 0 15px rgba(255, 221, 0, 0.4); } .glow.solar { box-shadow: 0 0 15px color-mix(in srgb, var(--neon-yellow), transparent 60%); }
.glow.battery { box-shadow: 0 0 15px rgba(0, 255, 136, 0.4); } .glow.battery { box-shadow: 0 0 15px color-mix(in srgb, var(--neon-green), transparent 60%); }
.glow.grid { box-shadow: 0 0 15px rgba(59, 130, 246, 0.4); } .glow.grid { box-shadow: 0 0 15px color-mix(in srgb, var(--neon-blue), transparent 60%); }
.glow.c1 { box-shadow: 0 0 15px rgba(168, 85, 247, 0.4); } .glow.c1 { box-shadow: 0 0 15px color-mix(in srgb, var(--consumer-1-color), transparent 60%); }
.glow.c2 { box-shadow: 0 0 15px rgba(249, 115, 22, 0.4); } .glow.c2 { box-shadow: 0 0 15px color-mix(in srgb, var(--consumer-2-color), transparent 60%); }
.glow.c3 { box-shadow: 0 0 15px rgba(6, 182, 212, 0.4); } .glow.c3 { box-shadow: 0 0 15px color-mix(in srgb, var(--consumer-3-color), transparent 60%); }
.node-solar { top: 70px; left: 5px; } .node-solar { top: 70px; left: 5px; }
.node-grid { top: 70px; left: 165px; } .node-grid { top: 70px; left: 165px; }
@ -296,9 +348,9 @@ console.log(
.bg-grid { stroke: var(--neon-blue); } .bg-grid { stroke: var(--neon-blue); }
.bg-battery { stroke: var(--neon-green); } .bg-battery { stroke: var(--neon-green); }
.bg-export { stroke: var(--neon-red); } .bg-export { stroke: var(--neon-red); }
.bg-c1 { stroke: #a855f7; } .bg-c1 { stroke: var(--consumer-1-color); }
.bg-c2 { stroke: #f97316; } .bg-c2 { stroke: var(--consumer-2-color); }
.bg-c3 { stroke: #06b6d4; } .bg-c3 { stroke: var(--consumer-3-color); }
.flow-line { .flow-line {
fill: none; stroke-width: var(--flow-stroke-width, 8px); stroke-linecap: round; stroke-dasharray: var(--flow-dasharray); fill: none; stroke-width: var(--flow-stroke-width, 8px); stroke-linecap: round; stroke-dasharray: var(--flow-dasharray);
@ -312,7 +364,7 @@ console.log(
@keyframes dash { to { stroke-dashoffset: -1500; } } @keyframes dash { to { stroke-dashoffset: -1500; } }
.flow-text { .flow-text {
font-size: 10px; font-weight: bold; text-anchor: middle; fill: #fff; filter: drop-shadow(0px 1px 2px rgba(0,0,0,0.8)); transition: opacity 0.3s ease; font-size: 10px; font-weight: bold; text-anchor: middle; fill: #fff; filter: transition: opacity 0.3s ease;
} }
.flow-text.no-shadow { filter: none; } .flow-text.no-shadow { filter: none; }
.text-solar { fill: var(--neon-yellow); } .text-solar { fill: var(--neon-yellow); }
@ -346,13 +398,16 @@ console.log(
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>`; 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') { if (type === 'car') {
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="#a855f7" 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>`; const c = colorOverride || 'var(--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') { if (type === 'heater') {
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="#f97316" 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>`; const c = colorOverride || 'var(--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') { if (type === 'pool') {
return html`<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" 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>`; const c = colorOverride || 'var(--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``; return html``;
} }
@ -365,6 +420,11 @@ console.log(
return Math.round(val) + " W"; return Math.round(val) + " W";
} }
_getConsumerColor(index) {
const style = getComputedStyle(this);
return style.getPropertyValue(`--consumer-${index}-color`).trim() || ['#a855f7', '#f97316', '#06b6d4'][index - 1];
}
// --- DOM NODE SVG GENERATOR --- // --- DOM NODE SVG GENERATOR ---
_renderSVGPath(d, color) { _renderSVGPath(d, color) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
@ -420,19 +480,27 @@ console.log(
}; };
const solar = entities.solar ? Math.max(0, getVal(entities.solar)) : 0; const solar = entities.solar ? Math.max(0, getVal(entities.solar)) : 0;
const gridMain = entities.grid ? getVal(entities.grid) : 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; const gridExportSensor = entities.grid_export ? getVal(entities.grid_export) : 0;
let battery = entities.battery ? getVal(entities.battery) : 0; let battery = entities.battery ? getVal(entities.battery) : 0;
if (this.config.invert_battery) { if (this.config.invert_battery) {
battery *= -1; battery *= -1;
} }
const c1Val = entities.consumer_1 ? getVal(entities.consumer_1) : 0; // EV Value let c1Val = entities.consumer_1 ? getVal(entities.consumer_1) : 0; // EV Value
if (this.config.invert_consumer_1) { c1Val *= -1; }
c1Val = Math.abs(c1Val);
// 2. Logic Calculation // 2. Logic Calculation
let gridImport = 0; let gridImport = 0;
let gridExport = 0; let gridExport = 0;
if (entities.grid_export && entities.grid_export !== "") { 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; gridImport = gridMain > 0 ? gridMain : 0;
gridExport = Math.abs(gridExportSensor); gridExport = Math.abs(gridExportSensor);
} else { } else {
@ -447,12 +515,18 @@ console.log(
let gridToBatt = 0; let gridToBatt = 0;
if (batteryCharge > 0) { if (batteryCharge > 0) {
if (solar >= batteryCharge) { const hasGridToBattSensor = !!(entities.grid_to_battery && entities.grid_to_battery !== "");
solarToBatt = batteryCharge; if (hasGridToBattSensor) {
gridToBatt = 0; gridToBatt = Math.abs(getVal(entities.grid_to_battery));
solarToBatt = Math.max(0, batteryCharge - gridToBatt);
} else { } else {
solarToBatt = solar; if (solar >= batteryCharge) {
gridToBatt = batteryCharge - solar; solarToBatt = batteryCharge;
gridToBatt = 0;
} else {
solarToBatt = solar;
gridToBatt = batteryCharge - solar;
}
} }
} }
@ -520,18 +594,18 @@ console.log(
currentX += width; currentX += width;
} }
addSegment(srcBattery, 'var(--neon-yellow)', 'battery', 'battery', entities.battery); addSegment(srcBattery, 'var(--neon-green)', 'battery', 'battery', entities.battery);
addSegment(srcSolar, 'var(--neon-green)', 'solar', 'solar', entities.solar); addSegment(srcSolar, 'var(--neon-yellow)', 'solar', 'solar', entities.solar);
addSegment(srcGrid, 'var(--grid-grey)', 'grid', 'grid', entities.grid); addSegment(srcGrid, 'var(--neon-blue)', 'grid', 'grid', entities.grid_combined || entities.grid);
// --- GENERATE TOP BRACKETS (Based on Bar Segments) --- // --- GENERATE TOP BRACKETS (Based on Bar Segments) ---
const topBrackets = barSegments.map(s => { const topBrackets = barSegments.map(s => {
const path = this._createBracketPath(s.startPx, s.widthPx, 'down'); const path = this._createBracketPath(s.startPx, s.widthPx, 'down');
let icon = ''; let icon = '';
let iconColor = ''; let iconColor = '';
if (s.type === 'solar') { icon = 'mdi:weather-sunny'; iconColor = 'var(--neon-green)'; } if (s.type === 'solar') { icon = 'mdi:weather-sunny'; iconColor = 'var(--neon-yellow)'; }
if (s.type === 'grid') { icon = 'mdi:transmission-tower'; iconColor = 'var(--grid-grey)'; } if (s.type === 'grid') { icon = 'mdi:transmission-tower'; iconColor = 'var(--neon-blue)'; }
if (s.type === 'battery') { icon = 'mdi:battery-high'; iconColor = 'var(--neon-yellow)'; } if (s.type === 'battery') { icon = 'mdi:battery-high'; iconColor = 'var(--neon-green)'; }
return { path, width: s.widthPx, center: s.startPx + (s.widthPx / 2), icon, iconColor, val: s.val, entityId: s.entityId }; return { path, width: s.widthPx, center: s.startPx + (s.widthPx / 2), icon, iconColor, val: s.val, entityId: s.entityId };
}); });
@ -550,7 +624,7 @@ console.log(
let iconColor = ''; let iconColor = '';
if (type === 'house') { icon = 'mdi:home'; iconColor = 'var(--primary-text-color)'; } if (type === 'house') { icon = 'mdi:home'; iconColor = 'var(--primary-text-color)'; }
if (type === 'car') { icon = 'mdi:car-electric'; iconColor = '#a855f7'; } if (type === 'car') { icon = 'mdi:car-electric'; iconColor = this._getConsumerColor(1); }
if (type === 'export') { icon = 'mdi:arrow-right-box'; iconColor = 'var(--export-purple)'; } if (type === 'export') { icon = 'mdi:arrow-right-box'; iconColor = 'var(--export-purple)'; }
if (type === 'battery') { icon = 'mdi:battery-charging-high'; iconColor = 'var(--neon-green)'; } if (type === 'battery') { icon = 'mdi:battery-charging-high'; iconColor = 'var(--neon-green)'; }
@ -569,7 +643,7 @@ console.log(
addBottomBracket(destHouse, 'house', entities.house); addBottomBracket(destHouse, 'house', entities.house);
addBottomBracket(destEV, 'car', entities.consumer_1); addBottomBracket(destEV, 'car', entities.consumer_1);
addBottomBracket(destExport, 'export', entities.grid_export || entities.grid); addBottomBracket(destExport, 'export', entities.grid_combined || entities.grid_export || entities.grid);
addBottomBracket(batteryCharge, 'battery', entities.battery); addBottomBracket(batteryCharge, 'battery', entities.battery);
// Note: If there is Battery Charging happening, bottomX will not reach fullWidth. // Note: If there is Battery Charging happening, bottomX will not reach fullWidth.
@ -651,7 +725,7 @@ console.log(
// CUSTOM LABELS // CUSTOM LABELS
const labelSolarText = this.config.solar_label || this._localize('card.label_solar'); const labelSolarText = this.config.solar_label || this._localize('card.label_solar');
const labelGridText = this.config.grid_label || this._localize('card.label_import'); 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 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'); const labelHouseText = this.config.house_label || this._localize('card.label_house');
@ -660,9 +734,31 @@ console.log(
const iconGrid = this.config.grid_icon; const iconGrid = this.config.grid_icon;
const iconBattery = this.config.battery_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' || unit === 'Wh') {
return this._formatPower(val);
}
if (unit === 'kWh' || unit === 'kW') {
return val.toFixed(1) + ' ' + unit;
}
return val.toFixed(1) + (unit ? ' ' + unit : '');
};
// Determine existence of main entities // Determine existence of main entities
const hasSolar = !!(entities.solar && entities.solar !== ""); const hasSolar = !!(entities.solar && entities.solar !== "");
const hasGrid = !!(entities.grid && entities.grid !== ""); const hasGridCombined = !!(entities.grid_combined && entities.grid_combined !== "");
const hasGrid = !!(entities.grid && entities.grid !== "") || hasGridCombined;
const hasBattery = !!(entities.battery && entities.battery !== ""); const hasBattery = !!(entities.battery && entities.battery !== "");
const styleSolar = hasSolar ? '' : 'display: none;'; const styleSolar = hasSolar ? '' : 'display: none;';
@ -683,7 +779,9 @@ console.log(
return state ? parseFloat(state.state) || 0 : 0; return state ? parseFloat(state.state) || 0 : 0;
}; };
const c1Val = entities.consumer_1 ? getVal(entities.consumer_1) : 0; let c1Val = entities.consumer_1 ? getVal(entities.consumer_1) : 0;
if (this.config.invert_consumer_1) { c1Val *= -1; }
c1Val = Math.abs(c1Val);
const c2Val = entities.consumer_2 ? getVal(entities.consumer_2) : 0; const c2Val = entities.consumer_2 ? getVal(entities.consumer_2) : 0;
const c3Val = entities.consumer_3 ? getVal(entities.consumer_3) : 0; const c3Val = entities.consumer_3 ? getVal(entities.consumer_3) : 0;
@ -693,7 +791,8 @@ console.log(
const anyBottomVisible = showC1 || showC2 || showC3; const anyBottomVisible = showC1 || showC2 || showC3;
const solar = hasSolar ? getVal(entities.solar) : 0; const solar = hasSolar ? getVal(entities.solar) : 0;
const gridMain = hasGrid ? getVal(entities.grid) : 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; const gridExpSensor = (hasGrid && entities.grid_export) ? getVal(entities.grid_export) : 0;
let battery = hasBattery ? getVal(entities.battery) : 0; let battery = hasBattery ? getVal(entities.battery) : 0;
if (this.config.invert_battery) { if (this.config.invert_battery) {
@ -707,7 +806,11 @@ console.log(
let gridExport = 0; let gridExport = 0;
if (hasGrid) { if (hasGrid) {
if (entities.grid_export && entities.grid_export !== "") { 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; gridImport = gridMain > 0 ? gridMain : 0;
gridExport = Math.abs(gridExpSensor); gridExport = Math.abs(gridExpSensor);
} else { } else {
@ -723,12 +826,20 @@ console.log(
let gridToBatt = 0; let gridToBatt = 0;
if (hasBattery && batteryCharge > 0) { if (hasBattery && batteryCharge > 0) {
if (solarVal >= batteryCharge) { const hasGridToBattSensor = !!(entities.grid_to_battery && entities.grid_to_battery !== "");
solarToBatt = batteryCharge; if (hasGridToBattSensor) {
gridToBatt = 0; // Use dedicated grid-to-battery sensor
gridToBatt = Math.abs(getVal(entities.grid_to_battery));
solarToBatt = Math.max(0, batteryCharge - gridToBatt);
} else { } else {
solarToBatt = solarVal; // Calculate: solar prioritized
gridToBatt = batteryCharge - solarVal; if (solarVal >= batteryCharge) {
solarToBatt = batteryCharge;
gridToBatt = 0;
} else {
solarToBatt = solarVal;
gridToBatt = batteryCharge - solarVal;
}
} }
} }
@ -751,6 +862,8 @@ console.log(
if (scale > 1.5) scale = 1.5; if (scale > 1.5) scale = 1.5;
const finalCardHeightPx = contentHeight * scale; const finalCardHeightPx = contentHeight * scale;
const visualWidth = 420 * scale;
const centerMarginLeft = Math.max(0, (availableWidth - visualWidth) / 2);
let houseGradientVal = ''; let houseGradientVal = '';
let houseTextCol = useColoredValues ? 'var(--neon-pink)' : ''; let houseTextCol = useColoredValues ? 'var(--neon-pink)' : '';
@ -777,11 +890,11 @@ console.log(
let stops = []; let stops = [];
let current = 0; let current = 0;
if (pctSolar > 0) { stops.push(`var(--neon-yellow) ${current}% ${current + pctSolar}%`); current += pctSolar; } if (pctSolar > 0) { stops.push(`var(--neon-yellow) ${current}% ${current + pctSolar}%`); current += pctSolar; }
if (pctGrid > 0) { stops.push(`var(--neon-blue) ${current}% ${current + pctGrid}%`); current += pctGrid; }
if (pctBatt > 0) { stops.push(`var(--neon-green) ${current}% ${current + pctBatt}%`); current += pctBatt; } 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%`); } if (current < 99.9) { stops.push(`var(--neon-pink) ${current}% 100%`); }
houseGradientVal = `conic-gradient(${stops.join(', ')})`; houseGradientVal = `conic-gradient(from 330deg, ${stops.join(', ')})`;
if (useColoredValues) { if (useColoredValues) {
const maxVal = Math.max(solarToHouse, gridToHouse, batteryDischarge); const maxVal = Math.max(solarToHouse, gridToHouse, batteryDischarge);
@ -812,25 +925,51 @@ console.log(
const houseBubbleStyle = `${showDonut ? `--house-gradient: ${houseGradientVal};` : ''} ${houseTintStyle} ${houseGlowStyle}`; const houseBubbleStyle = `${showDonut ? `--house-gradient: ${houseGradientVal};` : ''} ${houseTintStyle} ${houseGlowStyle}`;
const isSolarActive = Math.round(solarVal) > 0; const isSolarActive = Math.round(solarVal) > 0;
const isGridActive = Math.round(gridImport) > 0; const isGridActive = Math.round(gridImport) > 0 || Math.round(gridExport) > 0;
const isGridExporting = Math.round(gridExport) > 0 && Math.round(gridImport) === 0;
const solarColor = isSolarActive ? 'var(--neon-yellow)' : 'var(--secondary-text-color)'; const solarColor = isSolarActive ? 'var(--neon-yellow)' : 'var(--secondary-text-color)';
const gridColor = isGridActive ? 'var(--neon-blue)' : 'var(--secondary-text-color)'; const gridColor = isGridExporting ? 'var(--neon-red)' : (isGridActive ? 'var(--neon-blue)' : 'var(--secondary-text-color)');
const getAnimStyle = (val) => { const getAnimStyle = (val) => {
if (val <= 1) return "opacity: 0;"; if (val <= 1) return "opacity: 0;";
const userMinDuration = 7;
const userMaxDuration = 11;
const userFactor = 20000;
let duration = userFactor / val;
duration = Math.max(userMinDuration, Math.min(userMaxDuration, duration));
// Adjust speed for dashed line (Factor to slow down: 5x) // --- Dynamic speed based on power ---
if (showDashedLine) { // Higher power = faster animation (shorter duration)
duration = duration * 5; // 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);
} }
return `opacity: 1; animation-duration: ${duration}s;`; const dynamicDash = `${dashSize} ${gapSize}`;
return `opacity: 1; animation-duration: ${duration}s; stroke-dasharray: ${dynamicDash};`;
}; };
const getPipeStyle = (val) => { const getPipeStyle = (val) => {
@ -860,6 +999,14 @@ console.log(
return html`<div class="sub">${text}</div>`; 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) => { const renderMainIcon = (type, val, customIcon, color = null) => {
if (customIcon) { if (customIcon) {
const style = color ? `color: ${color};` : (type === 'solar' ? 'color: var(--neon-yellow);' : (type === 'grid' ? 'color: var(--neon-blue);' : (type === 'battery' ? 'color: var(--neon-green);' : ''))); const style = color ? `color: ${color};` : (type === 'solar' ? 'color: var(--neon-yellow);' : (type === 'grid' ? 'color: var(--neon-blue);' : (type === 'battery' ? 'color: var(--neon-green);' : '')));
@ -887,10 +1034,14 @@ console.log(
iconContent = this._renderIcon(iconType, val); iconContent = this._renderIcon(iconType, val);
} }
const secEntity = entities[`secondary_${configKey}`];
const hasSecondary = !!(secEntity && secEntity !== "");
return html` return html`
<div class="bubble ${cssClass} node ${cssClass.replace('c', 'node-c')} ${tintClass} ${dynamicClass} ${glowClass}"> <div class="bubble ${cssClass} node ${cssClass.replace('c', 'node-c')} ${tintClass} ${dynamicClass} ${glowClass}"
@click=${() => this._handleClick(entities[configKey])}>
${iconContent} ${iconContent}
${renderLabel(label, true)} ${renderSecondaryOrLabel(label, true, secEntity, hasSecondary)}
<div class="value" style="${getConsumerColorStyle(hexColor)}">${this._formatPower(val)}</div> <div class="value" style="${getConsumerColorStyle(hexColor)}">${this._formatPower(val)}</div>
</div> </div>
`; `;
@ -909,7 +1060,7 @@ console.log(
const pathSolarHouse = "M 50 160 Q 50 265 165 265"; const pathSolarHouse = "M 50 160 Q 50 265 165 265";
const pathSolarBatt = "M 50 70 Q 210 -20 370 70"; const pathSolarBatt = "M 50 70 Q 210 -20 370 70";
const pathGridImport = "M 210 160 L 210 220"; const pathGridImport = "M 210 160 L 210 220";
const pathGridExport = "M 165 115 Q 130 145 95 115"; const pathGridExport = "M 95 115 Q 130 145 165 115";
const pathGridToBatt = "M 255 115 Q 290 145 325 115"; const pathGridToBatt = "M 255 115 Q 290 145 325 115";
const pathBattHouse = "M 370 160 Q 370 265 255 265"; const pathBattHouse = "M 370 160 Q 370 265 255 265";
const pathHouseC1 = "M 165 265 Q 50 265 50 370"; const pathHouseC1 = "M 165 265 Q 50 265 50 370";
@ -923,7 +1074,7 @@ console.log(
return html` return html`
<ha-card style="height: ${finalCardHeightPx}px; --flow-dasharray: ${dashArrayVal}; --flow-stroke-width: ${strokeWidthVal}px;"> <ha-card style="height: ${finalCardHeightPx}px; --flow-dasharray: ${dashArrayVal}; --flow-stroke-width: ${strokeWidthVal}px;">
<div class="scale-wrapper" style="transform: scale(${scale});"> <div class="scale-wrapper" style="transform: scale(${scale}); margin-left: ${centerMarginLeft}px;">
<div class="absolute-container" style="height: ${baseHeight}px; top: -${topShift}px;"> <div class="absolute-container" style="height: ${baseHeight}px; top: -${topShift}px;">
<svg height="${baseHeight}" viewBox="0 0 420 ${baseHeight}" preserveAspectRatio="xMidYMid meet"> <svg height="${baseHeight}" viewBox="0 0 420 ${baseHeight}" preserveAspectRatio="xMidYMid meet">
@ -937,9 +1088,9 @@ console.log(
<path class="bg-path bg-battery" d="${pathBattHouse}" style="${getPipeStyle(batteryDischarge)} ${styleBattery}" /> <path class="bg-path bg-battery" d="${pathBattHouse}" style="${getPipeStyle(batteryDischarge)} ${styleBattery}" />
<path d="${pathHouseC1}" fill="none" stroke="#a855f7" stroke-width="6" style="${getConsumerPipeStyle(showC1, c1Val)}" /> <path d="${pathHouseC1}" fill="none" stroke="${this._getConsumerColor(1)}" stroke-width="6" style="${getConsumerPipeStyle(showC1, c1Val)}" />
<path d="${pathHouseC2}" fill="none" stroke="#f97316" stroke-width="6" style="${getConsumerPipeStyle(showC2, c2Val)}" /> <path d="${pathHouseC2}" fill="none" stroke="${this._getConsumerColor(2)}" stroke-width="6" style="${getConsumerPipeStyle(showC2, c2Val)}" />
<path d="${pathHouseC3}" fill="none" stroke="#06b6d4" stroke-width="6" style="${getConsumerPipeStyle(showC3, c3Val)}" /> <path d="${pathHouseC3}" fill="none" stroke="${this._getConsumerColor(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="${pathSolarHouse}" style="${getAnimStyle(solarToHouse)} ${styleSolar}" />
<path class="flow-line flow-solar" d="${pathSolarBatt}" style="${getAnimStyle(solarToBatt)} ${styleSolarBatt}" /> <path class="flow-line flow-solar" d="${pathSolarBatt}" style="${getAnimStyle(solarToBatt)} ${styleSolarBatt}" />
@ -950,9 +1101,9 @@ console.log(
<path class="flow-line flow-battery" d="${pathBattHouse}" style="${getAnimStyle(batteryDischarge)} ${styleBattery}" /> <path class="flow-line flow-battery" d="${pathBattHouse}" style="${getAnimStyle(batteryDischarge)} ${styleBattery}" />
<path class="flow-line" d="${pathHouseC1}" stroke="#a855f7" style="${getConsumerAnimStyle(showC1, c1Val)}" /> <path class="flow-line" d="${pathHouseC1}" stroke="${this._getConsumerColor(1)}" style="${getConsumerAnimStyle(showC1, c1Val)}" />
<path class="flow-line" d="${pathHouseC2}" stroke="#f97316" style="${getConsumerAnimStyle(showC2, c2Val)}" /> <path class="flow-line" d="${pathHouseC2}" stroke="${this._getConsumerColor(2)}" style="${getConsumerAnimStyle(showC2, c2Val)}" />
<path class="flow-line" d="${pathHouseC3}" stroke="#06b6d4" style="${getConsumerAnimStyle(showC3, c3Val)}" /> <path class="flow-line" d="${pathHouseC3}" stroke="${this._getConsumerColor(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="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="210" y="45" class="${textClass} text-solar" style="${getTextStyle(solarToBatt, 'solar')} ${styleSolarBatt}">${this._formatPower(solarToBatt)}</text>
@ -966,36 +1117,43 @@ console.log(
</svg> </svg>
${hasSolar ? html` ${hasSolar ? html`
<div class="bubble ${isSolarActive ? 'solar' : 'inactive'} node node-solar ${tintClass} ${isSolarActive ? glowClass : ''} ${getCustomClass(iconSolar)}"> <div class="bubble ${isSolarActive ? 'solar' : 'inactive'} node node-solar ${tintClass} ${isSolarActive ? glowClass : ''} ${getCustomClass(iconSolar)}"
@click=${() => this._handleClick(entities.solar)}>
${renderMainIcon('solar', solarVal, iconSolar, solarColor)} ${renderMainIcon('solar', solarVal, iconSolar, solarColor)}
${renderLabel(labelSolarText, showLabelSolar)} ${renderSecondaryOrLabel(labelSolarText, showLabelSolar, entities.secondary_solar, hasSecondarySolar)}
<div class="value" style="${isSolarActive ? getColorStyle('--neon-yellow') : `color: ${solarColor};`}">${this._formatPower(solarVal)}</div> <div class="value" style="${isSolarActive ? getColorStyle('--neon-yellow') : `color: ${solarColor};`}">${this._formatPower(solarVal)}</div>
</div>` : ''} </div>` : ''}
${hasGrid ? html` ${hasGrid ? html`
<div class="bubble ${isGridActive ? 'grid' : 'inactive'} node node-grid ${tintClass} ${isGridActive ? glowClass : ''} ${getCustomClass(iconGrid)}"> <div class="bubble ${isGridActive ? (isGridExporting ? 'grid exporting' : 'grid') : 'inactive'} node node-grid ${tintClass} ${isGridActive ? glowClass : ''} ${getCustomClass(iconGrid)}"
${renderMainIcon('grid', gridImport, iconGrid, gridColor)} @click=${() => this._handleClick(entities.grid_combined || entities.grid)}>
${renderLabel(labelGridText, showLabelGrid)} ${renderMainIcon('grid', isGridExporting ? gridExport : gridImport, iconGrid, gridColor)}
<div class="value" style="${isGridActive ? getColorStyle('--neon-blue') : `color: ${gridColor};`}">${this._formatPower(gridImport)}</div> ${renderSecondaryOrLabel(labelGridText, showLabelGrid, entities.secondary_grid, hasSecondaryGrid)}
<div class="value" style="color: ${gridColor};">
${isGridExporting ? html`<span class="direction-arrow">&#9650;</span>` : (isGridActive ? html`<span class="direction-arrow">&#9660;</span>` : '')}
${this._formatPower(isGridExporting ? gridExport : gridImport)}
</div>
</div>` : ''} </div>` : ''}
${hasBattery ? html` ${hasBattery ? html`
<div class="bubble battery node node-battery ${tintClass} ${glowClass} ${getCustomClass(iconBattery)}"> <div class="bubble battery node node-battery ${tintClass} ${glowClass} ${getCustomClass(iconBattery)}"
@click=${() => this._handleClick(entities.battery)}>
${renderMainIcon('battery', battSoc, iconBattery)} ${renderMainIcon('battery', battSoc, iconBattery)}
${renderLabel(labelBatteryText, showLabelBattery)} ${renderSecondaryOrLabel(labelBatteryText, showLabelBattery, entities.secondary_battery, hasSecondaryBattery)}
<div class="value" style="${getColorStyle('--neon-green')}">${Math.round(battSoc)}%</div> <div class="value" style="${getColorStyle('--neon-green')}">${Math.round(battSoc)}%</div>
</div>` : ''} </div>` : ''}
<div class="bubble house node node-house ${showDonut ? 'donut' : ''} ${tintClass}" <div class="bubble house node node-house ${showDonut ? 'donut' : ''} ${tintClass}"
style="${houseBubbleStyle}"> style="${houseBubbleStyle}"
@click=${() => this._handleClick(entities.house)}>
${renderMainIcon('house', 0, null, houseDominantColor)} ${renderMainIcon('house', 0, null, houseDominantColor)}
${renderLabel(labelHouseText, showLabelHouse)} ${renderLabel(labelHouseText, showLabelHouse)}
<div class="value" style="${houseTextStyle}">${this._formatPower(house)}</div> <div class="value" style="${houseTextStyle}">${this._formatPower(house)}</div>
</div> </div>
${renderConsumer(showC1, 'c1', 'consumer_1', labelC1, 'car', c1Val, '#a855f7')} ${renderConsumer(showC1, 'c1', 'consumer_1', labelC1, 'car', c1Val, this._getConsumerColor(1))}
${renderConsumer(showC2, 'c2', 'consumer_2', labelC2, 'heater', c2Val, '#f97316')} ${renderConsumer(showC2, 'c2', 'consumer_2', labelC2, 'heater', c2Val, this._getConsumerColor(2))}
${renderConsumer(showC3, 'c3', 'consumer_3', labelC3, 'pool', c3Val, '#06b6d4')} ${renderConsumer(showC3, 'c3', 'consumer_3', labelC3, 'pool', c3Val, this._getConsumerColor(3))}
</div> </div>
</div> </div>