import { } from "./power-flux-card-editor.js";
import lang_en from "./lang-en.js";
import lang_de from "./lang-de.js";
console.log(
"%c⚡ Power Flux Card v_2.1 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,
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: "",
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',
};
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;
--grid-grey: #9e9e9e;
--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);
--flow-dasharray: 0 380;
}
:host([data-theme-light]) {
--neon-yellow: #c8a800;
--neon-blue: #2563eb;
--neon-green: #059669;
--neon-pink: #db2777;
--neon-red: #dc2626;
--grid-grey: #6b7280;
--export-purple: #7c3aed;
--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;
}
/* Source Colors */
.src-solar { background: var(--neon-yellow); color: black; }
.src-grid { background: var(--neon-blue); color: black; }
.src-battery { background: var(--neon-green); color: black; }
/* --- 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; filter: 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(--neon-yellow)';
return html``;
}
if (type === 'grid') {
const animate = Math.round(val) > 0 ? 'pulse' : '';
const color = colorOverride || 'var(--neon-blue)';
return html``;
}
if (type === 'battery') {
const soc = Math.min(Math.max(val, 0), 100) / 100;
const rectHeight = 14 * soc;
const rectY = 18 - rectHeight;
const rectColor = soc > 0.2 ? 'var(--neon-green)' : 'var(--neon-red)';
return html``;
}
if (type === 'house') {
const strokeColor = colorOverride || 'var(--neon-pink)';
return html``;
}
if (type === 'car') {
const c = colorOverride || 'var(--consumer-1-color)';
return html``;
}
if (type === 'heater') {
const c = colorOverride || 'var(--consumer-2-color)';
return html``;
}
if (type === 'pool') {
const c = colorOverride || 'var(--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;
}
const batteryCharge = battery > 0 ? battery : 0;
const batteryDischarge = 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`