diff --git a/README.md b/README.md index b331d24..66a0584 100644 --- a/README.md +++ b/README.md @@ -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. - **Comet Tail / Dashed Lines**: Choose your preferred animation style. - **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. - **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 ### 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` - Type: JavaScript Module +--- + ### ⚙️ Configuration You can configure the card directly via the visual editor in Home Assistant. diff --git a/docs/README-de.md b/docs/README-de.md index 96f99ef..3e90900 100644 --- a/docs/README-de.md +++ b/docs/README-de.md @@ -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. - **Kometenschweif / Gestrichelte Linien**: Wählen Sie Ihren bevorzugten Animationsstil. - **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. - **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 ### 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` - Typ: JavaScript Module + +--- + ### ⚙️ Konfiguration Du kannst die Karte direkt über den visuellen Editor in Home Assistant konfigurieren. diff --git a/src/lang-de.js b/src/lang-de.js index bb15bc2..f7220c7 100644 --- a/src/lang-de.js +++ b/src/lang-de.js @@ -29,8 +29,16 @@ export default { "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 (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.label_solar": "Solar", diff --git a/src/lang-en.js b/src/lang-en.js index 774f660..f955cb9 100644 --- a/src/lang-en.js +++ b/src/lang-en.js @@ -29,8 +29,16 @@ export default { "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 (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.label_solar": "Solar", diff --git a/src/power-flux-card-editor.js b/src/power-flux-card-editor.js index fc488e0..bf1177a 100644 --- a/src/power-flux-card-editor.js +++ b/src/power-flux-card-editor.js @@ -65,10 +65,12 @@ class PowerFluxCardEditor extends LitElement { if (key) { const entityKeys = [ - 'solar', 'grid', 'grid_export', - 'battery', 'battery_soc', + 'solar', 'grid', 'grid_export', 'grid_combined', + 'battery', 'battery_soc', 'grid_to_battery', '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 }; @@ -101,6 +103,65 @@ class PowerFluxCardEditor extends LitElement { 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` +
+ + ${val ? html` this._clearEntity(configValue)} + >` : ''} +
+ `; + } + + _renderColorPicker(key, label, defaultColor) { + const currentColor = this._config[key] || defaultColor; + const hasCustom = !!this._config[key]; + return html` +
+ this._colorChanged(key, e)}> + ${label} + ${hasCustom ? html` this._resetColor(key)}>` : ''} +
+ `; + } + static get styles() { return css` .card-config { @@ -179,6 +240,60 @@ class PowerFluxCardEditor extends LitElement { 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: 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 {

${this._localize('editor.solar_section')}

- + ${this._renderEntitySelector(entitySelectorSchema, entities.solar, 'solar', this._localize('editor.entity'))}
@@ -222,6 +330,10 @@ class PowerFluxCardEditor extends LitElement { @value-changed=${this._valueChanged} > + ${this._renderEntitySelector(entitySelectorSchema, entities.secondary_solar || "", 'secondary_solar', this._localize('editor.secondary_sensor'))} + + ${this._renderColorPicker('color_solar', this._localize('editor.color_picker'), '#ffdd00')} +
@@ -253,23 +365,16 @@ class PowerFluxCardEditor extends LitElement {

${this._localize('editor.grid_section')}

- + ${this._renderEntitySelector(entitySelectorSchema, entities.grid_combined || "", 'grid_combined', this._localize('editor.grid_combined_sensor'))} +
+ ${this._localize('editor.grid_combined_hint')} +
- +
+ + ${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)")}
@@ -291,6 +396,10 @@ class PowerFluxCardEditor extends LitElement { @value-changed=${this._valueChanged} > + ${this._renderEntitySelector(entitySelectorSchema, entities.secondary_grid || "", 'secondary_grid', this._localize('editor.secondary_sensor'))} + + ${this._renderColorPicker('color_grid', this._localize('editor.color_picker'), '#3b82f6')} +
@@ -322,23 +431,14 @@ class PowerFluxCardEditor extends LitElement {

${this._localize('editor.battery_section')}

- + ${this._renderEntitySelector(entitySelectorSchema, entities.battery, 'battery', this._localize('editor.entity'))} - + ${this._renderEntitySelector(entitySelectorSchema, entities.battery_soc, 'battery_soc', this._localize('editor.battery_soc_label'))} + + ${this._renderEntitySelector(entitySelectorSchema, entities.grid_to_battery || "", 'grid_to_battery', this._localize('editor.grid_to_battery_sensor'))} +
+ ${this._localize('editor.grid_to_battery_hint')} +
@@ -359,6 +459,10 @@ class PowerFluxCardEditor extends LitElement { .label=${this._localize('editor.icon') + " (Optional)"} @value-changed=${this._valueChanged} > + + ${this._renderEntitySelector(entitySelectorSchema, entities.secondary_battery || "", 'secondary_battery', this._localize('editor.secondary_sensor'))} + + ${this._renderColorPicker('color_battery', this._localize('editor.color_picker'), '#00ff88')}
@@ -402,14 +506,7 @@ class PowerFluxCardEditor extends LitElement {
${this._localize('editor.house_total_title')}
- + ${this._renderEntitySelector(entitySelectorSchema, entities.house || "", 'house', this._localize('editor.house_sensor_label'))}
${this._localize('editor.house_sensor_hint')}
@@ -417,14 +514,7 @@ class PowerFluxCardEditor extends LitElement {
${this._localize('editor.consumer_1_title')}
- + ${this._renderEntitySelector(entitySelectorSchema, entities.consumer_1, 'consumer_1', this._localize('editor.entity'))} + +
+ ${this._localize('editor.invert_consumer_1')} + +
+ + ${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')}
${this._localize('editor.consumer_2_title')}
- + ${this._renderEntitySelector(entitySelectorSchema, entities.consumer_2, 'consumer_2', this._localize('editor.entity'))} + + ${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')}
${this._localize('editor.consumer_3_title')}
- + ${this._renderEntitySelector(entitySelectorSchema, entities.consumer_3, 'consumer_3', this._localize('editor.entity'))} + + ${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')}
`; } @@ -600,6 +697,15 @@ class PowerFluxCardEditor extends LitElement {
${this._localize('editor.dashed_line')}
+
+ +
${this._localize('editor.tinted_background')}
+
+
`; } if (type === 'car') { - return html``; + const c = colorOverride || 'var(--consumer-1-color)'; + return html``; } if (type === 'heater') { - return html``; + const c = colorOverride || 'var(--consumer-2-color)'; + return html``; } if (type === 'pool') { - return html``; + const c = colorOverride || 'var(--consumer-3-color)'; + return html``; } return html``; } @@ -365,6 +420,11 @@ console.log( 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 --- _renderSVGPath(d, color) { 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 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; let battery = entities.battery ? getVal(entities.battery) : 0; if (this.config.invert_battery) { 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 let gridImport = 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; gridExport = Math.abs(gridExportSensor); } else { @@ -447,12 +515,18 @@ console.log( let gridToBatt = 0; if (batteryCharge > 0) { - if (solar >= batteryCharge) { - solarToBatt = batteryCharge; - gridToBatt = 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 { - solarToBatt = solar; - gridToBatt = batteryCharge - solar; + if (solar >= batteryCharge) { + solarToBatt = batteryCharge; + gridToBatt = 0; + } else { + solarToBatt = solar; + gridToBatt = batteryCharge - solar; + } } } @@ -520,18 +594,18 @@ console.log( currentX += width; } - addSegment(srcBattery, 'var(--neon-yellow)', 'battery', 'battery', entities.battery); - addSegment(srcSolar, 'var(--neon-green)', 'solar', 'solar', entities.solar); - addSegment(srcGrid, 'var(--grid-grey)', 'grid', 'grid', entities.grid); + 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(--neon-green)'; } - if (s.type === 'grid') { icon = 'mdi:transmission-tower'; iconColor = 'var(--grid-grey)'; } - if (s.type === 'battery') { icon = 'mdi:battery-high'; iconColor = 'var(--neon-yellow)'; } + if (s.type === 'solar') { icon = 'mdi:weather-sunny'; iconColor = 'var(--neon-yellow)'; } + if (s.type === 'grid') { icon = 'mdi:transmission-tower'; iconColor = 'var(--neon-blue)'; } + 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 }; }); @@ -550,7 +624,7 @@ console.log( let iconColor = ''; 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 === 'battery') { icon = 'mdi:battery-charging-high'; iconColor = 'var(--neon-green)'; } @@ -569,7 +643,7 @@ console.log( addBottomBracket(destHouse, 'house', entities.house); 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); // Note: If there is Battery Charging happening, bottomX will not reach fullWidth. @@ -651,7 +725,7 @@ console.log( // CUSTOM LABELS 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 labelHouseText = this.config.house_label || this._localize('card.label_house'); @@ -660,9 +734,31 @@ console.log( 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' || 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 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 styleSolar = hasSolar ? '' : 'display: none;'; @@ -683,7 +779,9 @@ console.log( 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 c3Val = entities.consumer_3 ? getVal(entities.consumer_3) : 0; @@ -693,7 +791,8 @@ console.log( const anyBottomVisible = showC1 || showC2 || showC3; 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; let battery = hasBattery ? getVal(entities.battery) : 0; if (this.config.invert_battery) { @@ -707,7 +806,11 @@ console.log( let gridExport = 0; 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; gridExport = Math.abs(gridExpSensor); } else { @@ -723,12 +826,20 @@ console.log( let gridToBatt = 0; if (hasBattery && batteryCharge > 0) { - if (solarVal >= batteryCharge) { - solarToBatt = batteryCharge; - gridToBatt = 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 { - solarToBatt = solarVal; - gridToBatt = batteryCharge - solarVal; + // Calculate: solar prioritized + 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; const finalCardHeightPx = contentHeight * scale; + const visualWidth = 420 * scale; + const centerMarginLeft = Math.max(0, (availableWidth - visualWidth) / 2); let houseGradientVal = ''; let houseTextCol = useColoredValues ? 'var(--neon-pink)' : ''; @@ -777,11 +890,11 @@ console.log( let stops = []; let current = 0; 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 (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(${stops.join(', ')})`; + houseGradientVal = `conic-gradient(from 330deg, ${stops.join(', ')})`; if (useColoredValues) { const maxVal = Math.max(solarToHouse, gridToHouse, batteryDischarge); @@ -812,25 +925,51 @@ console.log( const houseBubbleStyle = `${showDonut ? `--house-gradient: ${houseGradientVal};` : ''} ${houseTintStyle} ${houseGlowStyle}`; 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 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) => { 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) - if (showDashedLine) { - duration = duration * 5; + // --- 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); } - return `opacity: 1; animation-duration: ${duration}s;`; + const dynamicDash = `${dashSize} ${gapSize}`; + + return `opacity: 1; animation-duration: ${duration}s; stroke-dasharray: ${dynamicDash};`; }; const getPipeStyle = (val) => { @@ -860,6 +999,14 @@ console.log( return html`
${text}
`; }; + const renderSecondaryOrLabel = (labelText, showLabel, secondaryEntity, hasSecondary) => { + if (hasSecondary) { + const secVal = getSecondaryVal(secondaryEntity); + return html`
${secVal}
`; + } + return renderLabel(labelText, showLabel); + }; + const renderMainIcon = (type, val, customIcon, color = null) => { 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);' : ''))); @@ -887,10 +1034,14 @@ console.log( iconContent = this._renderIcon(iconType, val); } + const secEntity = entities[`secondary_${configKey}`]; + const hasSecondary = !!(secEntity && secEntity !== ""); + return html` -
+
this._handleClick(entities[configKey])}> ${iconContent} - ${renderLabel(label, true)} + ${renderSecondaryOrLabel(label, true, secEntity, hasSecondary)}
${this._formatPower(val)}
`; @@ -909,7 +1060,7 @@ console.log( 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 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 pathBattHouse = "M 370 160 Q 370 265 255 265"; const pathHouseC1 = "M 165 265 Q 50 265 50 370"; @@ -923,7 +1074,7 @@ console.log( return html` -
+
@@ -937,9 +1088,9 @@ console.log( - - - + + + @@ -950,9 +1101,9 @@ console.log( - - - + + + ${this._formatPower(solarToHouse)} ${this._formatPower(solarToBatt)} @@ -966,36 +1117,43 @@ console.log( ${hasSolar ? html` -
+
this._handleClick(entities.solar)}> ${renderMainIcon('solar', solarVal, iconSolar, solarColor)} - ${renderLabel(labelSolarText, showLabelSolar)} + ${renderSecondaryOrLabel(labelSolarText, showLabelSolar, entities.secondary_solar, hasSecondarySolar)}
${this._formatPower(solarVal)}
` : ''} ${hasGrid ? html` -
- ${renderMainIcon('grid', gridImport, iconGrid, gridColor)} - ${renderLabel(labelGridText, showLabelGrid)} -
${this._formatPower(gridImport)}
+
this._handleClick(entities.grid_combined || entities.grid)}> + ${renderMainIcon('grid', isGridExporting ? gridExport : gridImport, iconGrid, gridColor)} + ${renderSecondaryOrLabel(labelGridText, showLabelGrid, entities.secondary_grid, hasSecondaryGrid)} +
+ ${isGridExporting ? html`` : (isGridActive ? html`` : '')} + ${this._formatPower(isGridExporting ? gridExport : gridImport)} +
` : ''} ${hasBattery ? html` -
+
this._handleClick(entities.battery)}> ${renderMainIcon('battery', battSoc, iconBattery)} - ${renderLabel(labelBatteryText, showLabelBattery)} + ${renderSecondaryOrLabel(labelBatteryText, showLabelBattery, entities.secondary_battery, hasSecondaryBattery)}
${Math.round(battSoc)}%
` : ''}
+ style="${houseBubbleStyle}" + @click=${() => this._handleClick(entities.house)}> ${renderMainIcon('house', 0, null, houseDominantColor)} ${renderLabel(labelHouseText, showLabelHouse)}
${this._formatPower(house)}
- ${renderConsumer(showC1, 'c1', 'consumer_1', labelC1, 'car', c1Val, '#a855f7')} - ${renderConsumer(showC2, 'c2', 'consumer_2', labelC2, 'heater', c2Val, '#f97316')} - ${renderConsumer(showC3, 'c3', 'consumer_3', labelC3, 'pool', c3Val, '#06b6d4')} + ${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))}