Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ jobs:
- uses: actions/checkout@v4
- uses: home-assistant/actions/hassfest@master

hacs:
runs-on: ubuntu-latest
steps:
- uses: hacs/action@main
with:
category: integration

tests:
runs-on: ubuntu-latest
steps:
Expand Down
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Standard energy dashboards show kWh. With dynamic prices (EPEX, Nordpool, Tibber
| Home Assistant **2024.11** or newer | Uses the current config and options flow APIs |
| [HACS](https://hacs.xyz/) | To install and update (manual install also works) |
| A **battery power** sensor | Power into and out of the battery, as two sensors or one signed sensor |
| A **price** sensor | Your dynamic tariff per kWh |
| A **price** sensor, or your fixed tariff | A dynamic tariff sensor works best. On a fixed or peak/off-peak contract you type in the prices instead |
| *Optional:* feed-in price, grid power, solar production, state of charge | Better accuracy and more on the card |

No extra Python packages are installed. The card's only library, [Lit](https://lit.dev), is bundled.
Expand All @@ -73,7 +73,7 @@ Write down the names of the sensors you'll pick in step 4.
| --- | --- | --- |
| Battery charge power *(required)* | `battery` + `power`, or your battery brand | Watts that go **into** the battery. Some batteries have one sensor that is positive while charging and negative while discharging; that's fine |
| Battery discharge power | `battery` + `power`, or your battery brand | Watts that come **out of** the battery. Skip if the sensor above is signed |
| Electricity price *(required)* | `price`, `tibber`, `nordpool`, `epex` | Your current price per kWh |
| Electricity price *(required, or a peak price)* | `price`, `tibber`, `nordpool`, `epex` | Your current price per kWh |
| Grid power *(recommended)* | `grid`, `p1`, `net`, `power_consumption` | Your smart meter or energy monitor: watts from (+) or to (−) the grid |
| Feed-in price *(recommended)* | `price` | What you get per exported kWh. With a dynamic contract this is often the same price sensor |
| Solar production | `solar`, `pv`, `inverter` | Watts your panels produce right now |
Expand Down Expand Up @@ -142,17 +142,21 @@ All fields explained:
| | Discharge power | | Power out of the battery. Leave empty when the field above is signed |
| | Positive power means | | Only for a signed sensor: does positive mean charging or discharging? |
| | State of charge | | Battery %, shown on the card |
| Prices | Electricity price (import) | ✅ | EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… |
| Prices | Electricity price sensor (dynamic) | ✅ or a peak price | EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… |
| | Extra cost per kWh, excl. VAT | | **Only for raw market prices** (EPEX, Nordpool): energy tax + supplier fee in EUR/kWh |
| | VAT % | | **Only for raw market prices**: 21 in the Netherlands |
| | Peak price | ✅ or a sensor | All-in price per kWh. On a single-rate contract enter your price here and leave off-peak empty. With a sensor too, it fills in whenever the sensor is unavailable |
| | Off-peak price | | Peak/off-peak contracts only: the all-in off-peak price |
| | Off-peak from / until | | When the off-peak price applies (default 23:00 to 07:00) |
| | Off-peak all weekend | | On by default: Saturday and Sunday are off-peak all day, as on most peak/off-peak contracts. Switch off if yours isn't |
| | Feed-in price sensor | | What you get per exported kWh, from a sensor |
| | Fixed feed-in price | | Same, as a fixed amount (0 if you get nothing). Use one of the two |
| | Net metering until | | The day net metering ends for you. Before it the feed-in price is ignored, after it it counts Optional; leave empty if you have no net metering |
| | Net metering until | | The day net metering ends for you. Before it the feed-in price is ignored, after it it counts. Optional; leave empty if you have no net metering |
| Grid and solar | Grid power | | Smart meter or energy monitor, + import / − export |
| | Solar production | | Shown on the card |
| Battery costs | Standby power | | Watts the battery uses itself that its power sensors miss |
| | Wear cost per kWh discharged | | Purchase price ÷ (capacity in kWh × rated cycles) |
| | Purchase cost | | What the battery cost you; adds a payback sensor |
| | Purchase cost | | What the battery cost you; adds a payback sensor and an expected payback date |
| | Battery installed on | | Fills in the totals from Home Assistant's history from this day on, see [Start from your install date](#start-from-your-install-date) |

> **Tibber, Frank Energie, Zonneplan** sensors already include tax and VAT: leave *Extra cost* and *VAT* empty.
Expand Down Expand Up @@ -193,6 +197,16 @@ Some numbers stay **Unknown** at first, on purpose:

**Judge the battery on a full day, and better on a full week.** A single charge without a discharge will always be red.

### What to expect on your contract

| Contract | Where the money comes from | What the card usually shows |
| --- | --- | --- |
| Dynamic (hourly prices) | Charging in cheap hours, discharging in dear ones, plus stored solar | Clearly positive on most days |
| Peak/off-peak | The gap between the two prices, often only a few cents, plus stored solar | Small; can be negative when the gap is smaller than the charging losses |
| Single rate | Only stored solar that you would otherwise have exported for less | Only charging losses, **unless** you enter a feed-in price and grid power |

A small or negative number on a fixed contract is not a bug: it is what the battery earns on price differences alone. The solar you keep is where the money is, so on a fixed contract a feed-in price and grid power are not optional.

## Reading the card

<picture>
Expand All @@ -208,7 +222,7 @@ Some numbers stay **Unknown** at first, on purpose:
| *Earning / Spending / Idle €…/h* | What the battery earns or costs **right now**, per hour |
| Price, ☀ kW, 🔋 % | Current price per kWh, solar production and battery level (only if you set those sensors) |
| Pace | This month's profit projected to a full month |
| Payback | Years until the battery has paid for itself at the rate so far |
| Paid back by | Month the battery has paid for itself at the rate so far, with the years left |
| Efficiency | kWh out ÷ kWh in; 80 to 90% is normal for home batteries |

Tap a tile or the ticker to see its history graph.
Expand Down Expand Up @@ -283,7 +297,7 @@ The **pace** line projects this month's profit to a full month, using the exact
| `sensor.battery_roi_energy_charged` | kWh into the battery |
| `sensor.battery_roi_energy_discharged` | kWh out of the battery |
| `sensor.battery_roi_efficiency` | Discharged ÷ charged, shown after 1 kWh |
| `sensor.battery_roi_payback` | Years until the battery has paid for itself at the average rate so far. Only exists if you entered a purchase cost; shown after 7 days with positive profit |
| `sensor.battery_roi_payback` | Years until the battery has paid for itself at the average rate so far. Attribute `paid_back_on` holds the expected date. Only exists if you entered a purchase cost; shown after 7 days with positive profit |
| `button.battery_roi_reset_totals` | Start all totals from zero |

Totals survive restarts. Gaps longer than 15 minutes (Home Assistant down, sensor offline) are skipped rather than guessed.
Expand Down
21 changes: 21 additions & 0 deletions custom_components/battery_roi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@
CONF_INSTALL_DATE,
CONF_NET_METERING_UNTIL,
CONF_POSITIVE_MEANS,
CONF_NIGHT_END,
CONF_NIGHT_START,
CONF_NIGHT_WEEKEND,
CONF_PRICE,
CONF_PRICE_FIXED,
CONF_PRICE_NIGHT,
CONF_PRICE_SURCHARGE,
CONF_SOC,
CONF_SOLAR_POWER,
Expand Down Expand Up @@ -209,6 +214,9 @@ def read(key: str, parse):
if price is not None:
price += self.config.get(CONF_PRICE_SURCHARGE) or 0
price *= 1 + (self.config.get(CONF_VAT) or 0) / 100
else:
# No price sensor, or it is unavailable: use the fixed tariff.
price = self._fixed_price(dt_util.as_local(at))
feed_in = read(CONF_FEED_IN_PRICE, _per_kwh)
if feed_in is None:
feed_in = self.config.get(CONF_FEED_IN_FIXED)
Expand Down Expand Up @@ -252,6 +260,19 @@ def read(key: str, parse):
)
return sample, live

def _fixed_price(self, at: datetime) -> float | None:
"""The fixed tariff, or the night tariff inside its hours (all-in prices)."""
night = self.config.get(CONF_PRICE_NIGHT)
if night is not None:
if self.config.get(CONF_NIGHT_WEEKEND) and at.weekday() >= 5:
return night
now = at.time()
start = time.fromisoformat(self.config.get(CONF_NIGHT_START, "23:00:00"))
end = time.fromisoformat(self.config.get(CONF_NIGHT_END, "07:00:00"))
if (start <= now < end) if start < end else (now >= start or now < end):
return night
return self.config.get(CONF_PRICE_FIXED)


def _backfill_message(hass: HomeAssistant, first: datetime | None, start: datetime) -> str:
nl = hass.config.language.startswith("nl")
Expand Down
14 changes: 13 additions & 1 deletion custom_components/battery_roi/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
CONF_INSTALL_DATE,
CONF_NET_METERING_UNTIL,
CONF_POSITIVE_MEANS,
CONF_NIGHT_END,
CONF_NIGHT_START,
CONF_NIGHT_WEEKEND,
CONF_PRICE,
CONF_PRICE_FIXED,
CONF_PRICE_NIGHT,
CONF_PRICE_SURCHARGE,
CONF_SOC,
CONF_SOLAR_POWER,
Expand Down Expand Up @@ -66,9 +71,14 @@ def _number(unit: str | None = None, step: float | str = "any", maximum: float |
),
}),
"prices": (False, {
vol.Required(CONF_PRICE): _SENSOR,
vol.Optional(CONF_PRICE): _SENSOR,
vol.Optional(CONF_PRICE_SURCHARGE): _number(maximum=1),
vol.Optional(CONF_VAT): _number("%", 0.1, 100),
vol.Optional(CONF_PRICE_FIXED): _number(maximum=5),
vol.Optional(CONF_PRICE_NIGHT): _number(maximum=5),
vol.Optional(CONF_NIGHT_START, default="23:00:00"): selector.TimeSelector(),
vol.Optional(CONF_NIGHT_END, default="07:00:00"): selector.TimeSelector(),
vol.Optional(CONF_NIGHT_WEEKEND, default=True): selector.BooleanSelector(),
vol.Optional(CONF_FEED_IN_PRICE): _SENSOR,
vol.Optional(CONF_FEED_IN_FIXED): _number(maximum=1),
vol.Optional(CONF_NET_METERING_UNTIL): selector.DateSelector(),
Expand Down Expand Up @@ -124,6 +134,8 @@ def _nest(flat: Mapping[str, Any]) -> dict[str, dict]:
def _errors(data: dict[str, Any]) -> dict[str, str]:
if data.get(CONF_DISCHARGE_POWER) == data[CONF_CHARGE_POWER]:
return {"base": "same_sensor"}
if not data.get(CONF_PRICE) and data.get(CONF_PRICE_FIXED) is None:
return {"base": "no_price"}
return {}


Expand Down
7 changes: 6 additions & 1 deletion custom_components/battery_roi/const.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"""Constants for Battery ROI."""

DOMAIN = "battery_roi"
VERSION = "0.4.0"
VERSION = "0.5.0"

CONF_CHARGE_POWER = "charge_power"
CONF_DISCHARGE_POWER = "discharge_power"
CONF_POSITIVE_MEANS = "positive_means"
CONF_PRICE = "price"
CONF_PRICE_SURCHARGE = "price_surcharge"
CONF_VAT = "vat"
CONF_PRICE_FIXED = "price_fixed"
CONF_PRICE_NIGHT = "price_night"
CONF_NIGHT_START = "night_start"
CONF_NIGHT_END = "night_end"
CONF_NIGHT_WEEKEND = "night_weekend"
CONF_FEED_IN_PRICE = "feed_in_price"
CONF_FEED_IN_FIXED = "feed_in_fixed"
CONF_NET_METERING_UNTIL = "net_metering_until"
Expand Down
12 changes: 9 additions & 3 deletions custom_components/battery_roi/frontend/battery-roi-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { LitElement, html, css, nothing } from "./lit.js";

// Must match VERSION in const.py (a test checks this).
const CARD_VERSION = "0.4.0";
const CARD_VERSION = "0.5.0";

const DEFAULTS = {
title: "Battery ROI",
Expand All @@ -25,7 +25,7 @@ const TEXT = {
en: {
today: "Today", yesterday: "Yesterday", month: "This month", lastMonth: "Last month", total: "Lifetime", since: "Since",
earning: "Earning", spending: "Spending", idle: "Idle",
pace: "Pace", perMonth: "/month", payback: "Payback", years: "y", efficiency: "Efficiency",
pace: "Pace", perMonth: "/month", payback: "Paid back by", years: "y", efficiency: "Efficiency",
missing: "Battery ROI isn't set up yet. Add it under Settings → Devices & services.",
updated: "Battery ROI was updated. Tap here to load the new card.",
},
Expand Down Expand Up @@ -224,13 +224,19 @@ class BatteryRoiCard extends LitElement {
? nothing
: html`<div class="outlook">
${pace === null ? nothing : html`<span>${t.pace} ${this._money(pace, { signed: true })}${t.perMonth}</span>`}
${payback === null ? nothing : html`<span>${t.payback} ${payback.toLocaleString(this._lang, { maximumFractionDigits: 1 })} ${t.years}</span>`}
${payback === null ? nothing : html`<span>${t.payback} ${this._paidBackOn(c.payback)}(${payback.toLocaleString(this._lang, { maximumFractionDigits: 1 })} ${t.years})</span>`}
${efficiency === null ? nothing : html`<span>${t.efficiency} ${Math.round(efficiency)}%</span>`}
</div>`}
</ha-card>
`;
}

_paidBackOn(entity) {
const on = this.hass.states[entity]?.attributes?.paid_back_on;
if (!on) return "";
return `${new Date(on).toLocaleDateString(this._lang, { month: "short", year: "numeric" })} `;
}

static styles = css`
.updated {
display: block;
Expand Down
2 changes: 1 addition & 1 deletion custom_components/battery_roi/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@
"issue_tracker": "https://github.com/PeterSlijkhuis/Battery-ROI/issues",
"requirements": [],
"single_config_entry": true,
"version": "0.4.0"
"version": "0.5.0"
}
13 changes: 12 additions & 1 deletion custom_components/battery_roi/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from collections.abc import Callable
from datetime import timedelta
from typing import Any

from homeassistant.components.sensor import (
Expand Down Expand Up @@ -82,13 +83,23 @@ def money(key: str, name: str, value, attrs=None, state_class=SensorStateClass.T
RoiSensor(
hub, entry, "payback", "Payback",
lambda: _round(hub.acc.payback_years(cost, dt_util.now()), 1),
lambda: {"battery_cost": cost, "tracking_since": hub.acc.totals.since},
lambda: {
"battery_cost": cost,
"tracking_since": hub.acc.totals.since,
"paid_back_on": _paid_back_on(hub.acc.payback_years(cost, dt_util.now())),
},
unit=UnitOfTime.YEARS, icon="mdi:scale-balance",
)
)
async_add_entities(entities)


def _paid_back_on(years: float | None) -> str | None:
if years is None:
return None
return (dt_util.now() + timedelta(days=years * 365.25)).date().isoformat()


def _round(value: float | None, digits: int = 2) -> float | None:
return None if value is None else round(value, digits)

Expand Down
Loading
Loading