/** * 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`
${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)}>` : ''}
`; } _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`
${items.map(item => { const color = this._config[item.key] || item.default; const hasCustom = !!this._config[item.key]; return html`
this._colorChanged(item.key, e)}> ${item.label} ${hasCustom ? html` this._resetColor(item.key)}>` : ''}
`; })}
`; } 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`
${this._localize('editor.back')}

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

${this._renderEntitySelector(entitySelectorSchema, entities.solar, 'solar', this._localize('editor.entity'))}
${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')}
${this._localize('editor.label_toggle')}
${this._localize('editor.flow_rate_title')}
`; } _renderGridView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) { return html`
${this._localize('editor.back')}

${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)")}
${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')}
${this._localize('editor.label_toggle')}
${this._localize('editor.flow_rate_title')}
`; } _renderBatteryView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) { return html`
${this._localize('editor.back')}

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

${this._renderEntitySelector(entitySelectorSchema, entities.battery, 'battery', this._localize('editor.entity'))}
${this._localize('editor.battery_separate_hint')}
${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'))}
${this._renderEntitySelector(entitySelectorSchema, entities.battery_soc, 'battery_soc', this._localize('editor.battery_soc_label'))}
${this._localize('editor.grid_to_battery_hint')}
${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')}
${this._localize('editor.label_toggle')}
${this._localize('editor.flow_rate_title')}
${this._localize('editor.invert_battery')}
`; } _renderConsumersView(entities, entitySelectorSchema, textSelectorSchema, iconSelectorSchema) { return html`
${this._localize('editor.back')}

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

${this._localize('editor.house_total_title')}
${this._renderEntitySelector(entitySelectorSchema, entities.house || "", 'house', this._localize('editor.house_sensor_label'))}
${this._localize('editor.house_sensor_hint')}
${this._renderColorPickerQuad('color_house', null, 'color_text_house', 'color_icon_house', '#ff0080')}
${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._localize('editor.consumer_1_hide_pipe')}
${this._config.consumer_1_hide_pipe === true ? html` ` : ''}
${this._localize('editor.consumer_unit_kw')}
${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')}
${this._localize('editor.consumer_2_title')}
${this._renderEntitySelector(entitySelectorSchema, entities.consumer_2, 'consumer_2', this._localize('editor.entity'))}
${this._localize('editor.consumer_unit_kw')}
${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')}
${this._localize('editor.consumer_3_title')}
${this._renderEntitySelector(entitySelectorSchema, entities.consumer_3, 'consumer_3', this._localize('editor.entity'))}
${this._localize('editor.consumer_unit_kw')}
${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')}
`; } 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`
${this._localize('editor.main_title')}
${this._localize('editor.options_section')}
${this._localize('editor.neon_glow')}
${this._localize('editor.donut_chart')}
${this._localize('editor.comet_tail')}
${this._localize('editor.dashed_line')}
${this._localize('editor.tinted_background')}
${this._localize('editor.colored_values')}
${this._localize('editor.hide_consumer_icons')}
${this._localize('editor.hide_inactive')}
${this._localize('editor.show_consumer_always')}
${this._localize('editor.compact_view')}
`; } } 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``; } if (type === 'grid') { const animate = Math.round(val) > 0 ? 'pulse' : ''; const color = colorOverride || 'var(--icon-grid-color)'; return html``; } 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``; } if (type === 'house') { const strokeColor = colorOverride || 'var(--icon-house-color)'; return html``; } if (type === 'car') { const c = colorOverride || 'var(--icon-consumer-1-color)'; return html``; } if (type === 'heater') { const c = colorOverride || 'var(--icon-consumer-2-color)'; return html``; } if (type === 'pool') { const c = colorOverride || 'var(--icon-consumer-3-color)'; return html``; } 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`
Waiting for data...
`; } // --- 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`
${topBrackets.map(b => this._renderSVGPath(b.path, b.iconColor))} ${topBrackets.map(b => b.width > 20 ? html`
b.entityId && this._handleClick(b.entityId)}>
` : '')}
${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`
s.entityId && this._handleClick(s.entityId)}> ${s.widthPx > 35 ? this._formatPower(s.val) : ''}
`})}
${bottomBrackets.map(b => this._renderSVGPath(b.path, b.iconColor))} ${bottomBrackets.map(b => b.width > 20 ? html`
b.entityId && this._handleClick(b.entityId)}>
` : '')}
`; } // --- 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`
${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(--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``; } 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``; } 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`
this._handleClick(entities[configKey])}> ${iconContent} ${renderSecondaryOrLabel(label, true, secEntity, hasSecondary)}
${this._formatPower(val)}
`; }; 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`
${this._formatPower(solarToHouse)} ${this._formatPower(solarToBatt)} ${this._formatPower(gridToHouse)} ${this._formatPower(gridExport)} ${this._formatPower(gridToBatt)} ${this._formatPower(batteryDischarge)} ${hasSolar ? html`
this._handleClick(entities.solar)}> ${renderMainIcon('solar', solarVal, iconSolar, solarColor)} ${renderSecondaryOrLabel(labelSolarText, showLabelSolar, entities.secondary_solar, hasSecondarySolar)}
${this._formatPower(solarVal)}
` : ''} ${hasGrid ? html`
this._handleClick(entities.grid_combined || entities.grid)}> ${renderMainIcon('grid', isGridExporting ? gridExport : gridImport, iconGrid, gridIconColor)} ${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)} ${renderSecondaryOrLabel(labelBatteryText, showLabelBattery, entities.secondary_battery, hasSecondaryBattery)}
${Math.round(battSoc)}%
` : ''}
this._handleClick(entities.house)}> ${renderMainIcon('house', 0, null, this.config.color_icon_house ? 'var(--icon-house-color)' : houseDominantColor)} ${renderLabel(labelHouseText, showLabelHouse)}
${this._formatPower(houseDisplay)}
${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))}
`; } 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", });