/**
*
* power-wheel-card by Gerben ten Hove
* https://github.com/gurbyz/power-wheel-card
*
*/
const __VERSION = "0.1.5";
const LitElement = customElements.get('hui-masonry-view')
? Object.getPrototypeOf(customElements.get('hui-masonry-view'))
: Object.getPrototypeOf(customElements.get('hui-view'));
const html = LitElement.prototype.html;
const css = LitElement.prototype.css;
class PowerWheelCard extends LitElement {
static get properties() {
return {
hass: { type: Object },
config: { type: Object },
autoToggleView: { type: Boolean },
autoToggleViewTimerId: { type: Number },
data: { type: Object },
messages: { type: Array },
sensors: { type: Array },
view: { type: String },
views: { type: Object },
}
}
static get styles() {
return [css`
ha-card {
padding: 16px;
}
ha-card .header {
font-family: var(--paper-font-headline_-_font-family);
-webkit-font-smoothing: var(--paper-font-headline_-_-webkit-font-smoothing);
font-size: var(--paper-font-headline_-_font-size);
font-weight: var(--paper-font-headline_-_font-weight);
letter-spacing: var(--paper-font-headline_-_letter-spacing);
line-height: var(--paper-font-headline_-_line-height);
color: var(--primary-text-color);
padding: 4px 0 12px;
display: flex;
justify-content: space-between;
min-height: 12px;
}
ha-card .wheel {
position: relative;
}
ha-card .row {
display: flex;
justify-content: center;
padding: 8px;
align-items: center;
height: 60px;
}
ha-card .cell {
text-align: center;
width: 75px;
transition: opacity 0.4s ease-in-out;
}
ha-card .cell.position {
font-weight: bold;
}
ha-card .cell.arrow {
color: var(--state-icon-unavailable-color, #bdbdbd);
}
ha-card .cell.sensor {
cursor: pointer;
}
ha-card .cell.hidden {
opacity: 0;
}
.value {
min-height: 16px;
}
.unit-container {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 230px;
}
#unit {
padding: 3px 10px;
font-size: calc(1.5 * var(--paper-font-headline_-_font-size));
}
#unit.toggle {
cursor: pointer;
}
ha-icon {
--mdc-icon-size: 48px;
transition: all 0.4s ease-in-out;
color: var(--paper-item-icon-color, #44739e);
width: 48px;
height: 48px;
}
ha-icon.inactive {
color: var(--state-icon-unavailable-color, #bdbdbd);
}
ha-icon#toggle-button {
--mdc-icon-size: 24px;
padding-top: 4px;
width: 24px;
height: 24px;
float: right;
cursor: pointer;
}
.message {
display: block;
color: white;
padding: 8px;
font-weight: 500;
margin-bottom: 3px;
}
.message.error {
background-color: #ef5350;
}
.message.warn {
background-color: #fdd835;
}
`];
}
/* Card functions */
static _generateClass(value) {
return value > 0 ? 'producing' : ((value < 0) ? 'consuming' : 'inactive');
}
_generateValueStr(value, decimals) {
return this.config.color_icons ? Math.abs(value).toFixed(decimals): value.toFixed(decimals);
}
_makePositionObject(val, entity, configIcon, defaultIcon, decimals, valSoC) {
const valueStr = typeof val === 'undefined' ? 'unavailable' : this._generateValueStr(val, decimals);
const valueStrSoC = typeof valSoC === 'undefined' ? 'unavailable' : `(${valSoC}%)`;
const stateObj = this.hass.states[entity];
const icon = configIcon ? configIcon : (stateObj && stateObj.attributes.icon ? stateObj.attributes.icon : defaultIcon);
// Invert producing/consuming for grid icon when user wants to invert_grid_colors
const classValue = PowerWheelCard._generateClass(val *
(defaultIcon === 'mdi:transmission-tower' && this.config.invert_grid_colors ? -1 : 1));
return {
stateObj,
valueStr,
val,
icon,
classValue,
hasSensor: !!stateObj && this.view !== 'money',
valSoC,
valueStrSoC,
}
}
_makeArrowObject(val, entity, iconNormal, iconReversed, decimals) {
const valueStr = typeof val === 'undefined' ? 'unavailable' : Math.abs(val).toFixed(decimals);
const stateObj = entity ? this.hass.states[entity] : false;
const classValue = typeof val === 'undefined' || val === 0 ? 'inactive' : 'active';
const icon = typeof val === 'undefined' || val >= 0 ? iconNormal : iconReversed;
return {
stateObj,
valueStr,
val,
icon,
classValue,
hasSensor: !!stateObj,
}
}
// Get numeric state value or undefined
_getEntityState(entity) {
const stateObj = this.hass.states[entity];
return stateObj ? parseFloat(stateObj.state) : undefined;
}
_setPolarity(value) {
return typeof value === 'undefined'
? undefined : value * this.config.production_is_positive;
}
_setBatteryPolarity(value) {
return typeof value === 'undefined'
? undefined : value * this.config.charging_is_positive;
}
// Get all entity states (view dependent) and save in this.input
// Since battery functions this.input.grid_solo_production is not always the same as this.data.solar2grid anymore.
_saveEntityStates(solar_entity, grid_production_entity, grid_consumption_entity, battery_entity, grid_entity, home_entity) {
this.input.solar_production = this._getEntityState(solar_entity);
this.input.battery_charging = this._setBatteryPolarity(this._getEntityState(battery_entity));
this.input.home_production = this._setPolarity(this._getEntityState(home_entity));
if (this.views[this.view].twoGridSensors) {
this.input.grid_solo_production = this._getEntityState(grid_production_entity);
this.input.grid_solo_consumption = this._getEntityState(grid_consumption_entity);
} else {
const grid_nett_production = this._setPolarity(this._getEntityState(grid_entity));
this.input.grid_solo_production = grid_nett_production > 0 ? grid_nett_production : 0;
this.input.grid_solo_consumption = grid_nett_production < 0 ? -grid_nett_production : 0;
}
}
_calculateSolarValue() {
return this.input.solar_production;
}
_calculateBatteryValue() {
return this.input.battery_charging;
}
_calculateGridValue() {
return this.input.grid_solo_production - this.input.grid_solo_consumption;
}
_calculateHomeValue() {
return this.input.grid_solo_production - this.input.grid_solo_consumption
+ (this.input.battery_charging || 0) - this.input.solar_production;
}
_calculateGrid2BatteryValue() {
if (this.data.battery.val > 0) {
// Battery charged by grid, but maxed to what is consumed by the grid
return Math.min(this.data.battery.val, this.input.grid_solo_consumption);
} else {
// Battery discharging to grid, but maxed to what is produced to the grid
return -Math.min(-this.data.battery.val, this.input.grid_solo_production);
}
}
_calculateGrid2HomeValue() {
if (this.views[this.view].twoGridSensors || this.view === 'power') {
return this.input.grid_solo_consumption - Math.max(0, this.data.grid2battery.val || 0);
} else {
return 0;
}
}
_calculateSolar2HomeValue() {
if (this.views[this.view].twoGridSensors || this.view === 'power') {
if (this.view === 'power') {
// Assumption: Solar is consumed by home first
return Math.min((this.data.solar.val || 0), (-this.data.home.val || 0));
} else {
// todo: strange that this is needed, why?
return Math.min((this.data.solar.val || 0), (-this.data.home.val || 0) - (this.data.grid2home.val || 0));
}
} else {
return 0;
}
}
_calculateSolar2BatteryValue() {
// Assumption: What's left of solar (after home did consume) is used for charging the battery
return Math.max(0, Math.min(this.data.solar.val - this.data.solar2home.val, (this.data.battery.val || 0)));
}
_calculateSolar2GridValue() {
if (this.views[this.view].twoGridSensors || this.view === 'power') {
// Assumption: What's left of solar (after home and battery did consume) is produced to the grid
return this.data.solar.val - this.data.solar2battery.val - this.data.solar2home.val;
} else {
return 0;
}
}
_calculateBattery2HomeValue() {
return -this.data.home.val - this.data.grid2home.val - this.data.solar2home.val;
}
_performCalculations() {
this.data.solar.val = this._calculateSolarValue();
this.data.battery.val = this._calculateBatteryValue();
this.data.grid.val = this._calculateGridValue();
this.data.home.val = this._calculateHomeValue();
this.data.grid2battery.val = this._calculateGrid2BatteryValue();
this.data.grid2home.val = this._calculateGrid2HomeValue();
this.data.solar2home.val = this._calculateSolar2HomeValue();
this.data.solar2battery.val = this._calculateSolar2BatteryValue();
this.data.solar2grid.val = this._calculateSolar2GridValue();
this.data.battery2home.val = this._calculateBattery2HomeValue();
}
static _logConsole(message) {
console.info(`%cpower-wheel-card%c\n${message}`, "color: green; font-weight: bold", "");
}
_getSensorUnit(entity) {
if (entity) {
const stateObj = this.hass.states[entity];
const unit = stateObj && stateObj.attributes.unit_of_measurement
? stateObj.attributes.unit_of_measurement : undefined;
if (stateObj && !unit) {
this._addMessage('error', `Attribute "unit_of_measurement" for the entity "${entity}" not found in HA.`);
}
return unit;
} else {
return undefined;
}
}
_defineUnit(view, solar_entity, grid_entity, grid_consumption_entity, grid_production_entity) {
const solarUnit = this._getSensorUnit(solar_entity);
let gridUnit;
if (this.views[view].twoGridSensors) {
const gridConsumptionUnit = this._getSensorUnit(grid_consumption_entity);
const gridProductionUnit = this._getSensorUnit(grid_production_entity);
gridUnit = gridConsumptionUnit === gridProductionUnit ? gridConsumptionUnit : undefined;
} else if (this.views[view].oneGridSensor) {
gridUnit = this._getSensorUnit(grid_entity);
} else {
gridUnit = undefined;
}
if (solarUnit === gridUnit) {
return solarUnit;
} else {
this._addMessage('error', `Units not equal for all sensors for the ${view} view.`);
return 'error';
}
}
/* Lit functions */
constructor() {
super();
this.data = {
solar: {},
solar2battery: {},
battery: {},
batterySoC: {},
grid2battery: {},
solar2grid: {},
solar2home: {},
battery2home: {},
grid: {},
grid2home: {},
home: {},
};
this.input = {};
this.messages = [];
this.sensors = [];
this.view = 'power';
this.views = {
power: {},
energy: {},
money: {},
};
}
_lovelaceResource() {
const scripts = document.getElementsByTagName("script");
let src = '404';
Object.keys(scripts).some((key) => {
let pos = scripts[key].src.indexOf("power-wheel-card.js");
if (pos !== -1) {
src = scripts[key].src.substr(pos);
return true;
} else {
return false;
}
});
return src;
}
_addMessage(type, text, publishInConsole = true) {
this.messages.push({ type: type, text: text });
if (publishInConsole) {
console[type](text);
}
}
_validateSensors() {
this.sensors.forEach(sensor => {
if (!this.hass.states[sensor]) {
this._addMessage('error', `Entity "${sensor}" not found in HA.`);
}
});
}
firstUpdated(changedProperties) {
if (this.config.debug) {
let line = `Version: ${__VERSION}\nLovelace resource: ${this._lovelaceResource()}\nHA version: ${this.hass.config.version}`;
line += `\nAgent: ${navigator.userAgent}`;
line += `\nReport issues here: https://github.com/gurbyz/power-wheel-card/issues`;
line += `\nProcessed config: ${JSON.stringify(this.config, null, ' ')}\nRegistered sensors: ${JSON.stringify(this.sensors, null, ' ')}`;
line += `\nViews object: ${JSON.stringify(this.views, null, ' ')}`;
PowerWheelCard._logConsole(line);
this._addMessage('warn', `[${__VERSION}] Debug mode is on.`, false);
} else {
PowerWheelCard._logConsole(`Version: ${__VERSION}`);
}
// this._validateSensors(); // todo: Have to find a better trigger than firstUpdated. Disabled in version 0.1.2 because new startup order in HA 0.111.0.
}
_sensorChangeDetected(oldValue) {
return this.sensors.reduce((change, sensor) => {
return change || this.hass.states[sensor].state !== oldValue.states[sensor].state;
}, false);
}
shouldUpdate(changedProperties) {
// Don't update when there is a new value for a hass property that's not a registered sensor.
// Update in all other cases, e.g. when there is a change of config or old values are undefined.
let update = true;
Array.from(changedProperties.keys()).some((propName) => {
const oldValue = changedProperties.get(propName);
if (propName === "hass" && oldValue) {
update = update && this._sensorChangeDetected(oldValue);
}
return !update;
});
return update;
}
render() {
this.views.power.unit = this._defineUnit('power', this.config.solar_power_entity, this.config.grid_power_entity,
this.config.grid_power_consumption_entity, this.config.grid_power_production_entity);
this.views.energy.unit = this._defineUnit('energy', this.config.solar_energy_entity, this.config.grid_energy_entity,
this.config.grid_energy_consumption_entity, this.config.grid_energy_production_entity);
this.views.money.unit = this.config.money_unit;
if (this.view === 'money' && this.views.money.capable) {
// Calculate energy values first
this._saveEntityStates(this.config.solar_energy_entity, this.config.grid_energy_production_entity, this.config.grid_energy_consumption_entity, false, this.config.grid_energy_entity, this.config.home_energy_entity);
this._performCalculations();
// Convert energy values into money values
this.data.solar2grid.val *= this.config.energy_production_rate;
this.data.grid2home.val *= this.config.energy_consumption_rate;
this.data.solar2home.val *= this.config.energy_consumption_rate;
this.data.solar.val = this.data.solar2grid.val + this.data.solar2home.val;
this.data.grid.val = this.data.solar2grid.val - this.data.grid2home.val;
this.data.home.val = - this.data.grid2home.val - this.data.solar2home.val;
this.data.solar = this._makePositionObject(this.data.solar.val, this.config.solar_energy_entity, this.config.solar_icon,
'mdi:weather-sunny', this.config.money_decimals);
this.data.grid = this._makePositionObject(this.data.grid.val, this.config.grid_energy_entity, this.config.grid_icon,
'mdi:transmission-tower', this.config.money_decimals);
this.data.home = this._makePositionObject(this.data.home.val, this.config.home_energy_entity, this.config.home_icon,
'mdi:home', this.config.money_decimals);
this.data.solar2grid = this._makeArrowObject(this.data.solar2grid.val, false, 'mdi:arrow-bottom-left', 'mdi:arrow-top-right', this.config.money_decimals);
this.data.solar2home = this._makeArrowObject(this.data.solar2home.val, false, 'mdi:arrow-bottom-right', 'mdi:arrow-top-left', this.config.money_decimals);
this.data.grid2home = this._makeArrowObject(this.data.grid2home.val, false, 'mdi:arrow-right', 'mdi:arrow-left', this.config.money_decimals);
} else if (this.view === 'energy' && this.views.energy.capable) {
this._saveEntityStates(this.config.solar_energy_entity, this.config.grid_energy_production_entity, this.config.grid_energy_consumption_entity, false, this.config.grid_energy_entity, this.config.home_energy_entity);
this._performCalculations();
this.data.solar = this._makePositionObject(this.data.solar.val, this.config.solar_energy_entity, this.config.solar_icon,
'mdi:weather-sunny', this.config.energy_decimals);
this.data.grid = this._makePositionObject(this.data.grid.val, this.config.grid_energy_entity, this.config.grid_icon,
'mdi:transmission-tower', this.config.energy_decimals);
this.data.home = this._makePositionObject(this.data.home.val, this.config.home_energy_entity, this.config.home_icon,
'mdi:home', this.config.energy_decimals);
this.data.solar2grid = this._makeArrowObject(this.data.solar2grid.val, this.config.grid_energy_production_entity, 'mdi:arrow-bottom-left', 'mdi:arrow-top-right', this.config.energy_decimals);
this.data.solar2home = this._makeArrowObject(this.data.solar2home.val, false, 'mdi:arrow-bottom-right', 'mdi:arrow-top-left', this.config.energy_decimals);
this.data.grid2home = this._makeArrowObject(this.data.grid2home.val, this.config.grid_energy_consumption_entity, 'mdi:arrow-right', 'mdi:arrow-left', this.config.energy_decimals);
} else {
this._saveEntityStates(this.config.solar_power_entity, this.config.grid_power_production_entity, this.config.grid_power_consumption_entity, this.config.battery_power_entity, this.config.grid_power_entity, false);
this._performCalculations();
this.data.batterySoC.val = this._getEntityState(this.config.battery_soc_entity);
this.data.solar = this._makePositionObject(this.data.solar.val, this.config.solar_power_entity, this.config.solar_icon,
'mdi:weather-sunny', this.config.power_decimals);
this.data.grid = this._makePositionObject(this.data.grid.val, this.config.grid_power_entity, this.config.grid_icon,
'mdi:transmission-tower', this.config.power_decimals);
this.data.home = this._makePositionObject(this.data.home.val, this.config.home_power_entity, this.config.home_icon,
'mdi:home', this.config.power_decimals);
this.data.battery = this._makePositionObject(this.data.battery.val, this.config.battery_soc_entity, this.config.battery_icon,
'mdi:car-battery', this.config.power_decimals, this.data.batterySoC.val);
this.data.solar2grid = this._makeArrowObject(this.data.solar2grid.val, this.config.grid_power_production_entity, 'mdi:arrow-bottom-left', 'mdi:arrow-top-right', this.config.power_decimals);
this.data.solar2home = this._makeArrowObject(this.data.solar2home.val, false, 'mdi:arrow-bottom-right', 'mdi:arrow-top-left', this.config.power_decimals);
this.data.grid2home = this._makeArrowObject(this.data.grid2home.val, this.config.grid_power_consumption_entity, 'mdi:arrow-right', 'mdi:arrow-left', this.config.power_decimals);
this.data.solar2battery = this._makeArrowObject(this.data.solar2battery.val, this.config.battery_power_entity, 'mdi:arrow-right', 'mdi:arrow-left', this.config.power_decimals);
this.data.battery2home = this._makeArrowObject(this.data.battery2home.val, this.config.battery_power_entity, 'mdi:arrow-down', 'mdi:arrow-up', this.config.power_decimals);
this.data.grid2battery = this._makeArrowObject(this.data.grid2battery.val, this.config.battery_power_entity, 'mdi:arrow-up', 'mdi:arrow-down', this.config.power_decimals);
}
if (this.autoToggleView) {
this.autoToggleViewTimerId = this.autoToggleViewTimerId || setInterval(() => {
this._toggleViewHelper();
}, this.config.auto_toggle_view_period * 1000);
} else if (this.autoToggleViewTimerId) {
this.autoToggleViewTimerId = clearInterval(this.autoToggleViewTimerId);
}
return html`