diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4612e42..a74d395 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/README.md b/README.md index 0fb554e..8ddb037 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | @@ -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. @@ -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 @@ -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. @@ -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. diff --git a/custom_components/battery_roi/__init__.py b/custom_components/battery_roi/__init__.py index 91b9822..79ed56b 100644 --- a/custom_components/battery_roi/__init__.py +++ b/custom_components/battery_roi/__init__.py @@ -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, @@ -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) @@ -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") diff --git a/custom_components/battery_roi/config_flow.py b/custom_components/battery_roi/config_flow.py index a94418e..6ed45f1 100644 --- a/custom_components/battery_roi/config_flow.py +++ b/custom_components/battery_roi/config_flow.py @@ -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, @@ -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(), @@ -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 {} diff --git a/custom_components/battery_roi/const.py b/custom_components/battery_roi/const.py index 1e5e57b..082915c 100644 --- a/custom_components/battery_roi/const.py +++ b/custom_components/battery_roi/const.py @@ -1,7 +1,7 @@ """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" @@ -9,6 +9,11 @@ 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" diff --git a/custom_components/battery_roi/frontend/battery-roi-card.js b/custom_components/battery_roi/frontend/battery-roi-card.js index 2db5492..287a34a 100644 --- a/custom_components/battery_roi/frontend/battery-roi-card.js +++ b/custom_components/battery_roi/frontend/battery-roi-card.js @@ -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", @@ -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.", }, @@ -224,13 +224,19 @@ class BatteryRoiCard extends LitElement { ? nothing : html`
${pace === null ? nothing : html`${t.pace} ${this._money(pace, { signed: true })}${t.perMonth}`} - ${payback === null ? nothing : html`${t.payback} ${payback.toLocaleString(this._lang, { maximumFractionDigits: 1 })} ${t.years}`} + ${payback === null ? nothing : html`${t.payback} ${this._paidBackOn(c.payback)}(${payback.toLocaleString(this._lang, { maximumFractionDigits: 1 })} ${t.years})`} ${efficiency === null ? nothing : html`${t.efficiency} ${Math.round(efficiency)}%`}
`} `; } + _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; diff --git a/custom_components/battery_roi/manifest.json b/custom_components/battery_roi/manifest.json index 6d080ce..cb3520c 100644 --- a/custom_components/battery_roi/manifest.json +++ b/custom_components/battery_roi/manifest.json @@ -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" } diff --git a/custom_components/battery_roi/sensor.py b/custom_components/battery_roi/sensor.py index 2600575..8831107 100644 --- a/custom_components/battery_roi/sensor.py +++ b/custom_components/battery_roi/sensor.py @@ -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 ( @@ -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) diff --git a/custom_components/battery_roi/strings.json b/custom_components/battery_roi/strings.json index dbaf915..956983b 100644 --- a/custom_components/battery_roi/strings.json +++ b/custom_components/battery_roi/strings.json @@ -29,17 +29,27 @@ "prices": { "name": "Prices", "data": { - "price": "Electricity price (import)", + "price": "Electricity price sensor (dynamic)", "price_surcharge": "Extra cost per kWh, excluding VAT", "vat": "VAT", + "price_fixed": "Peak price", + "price_night": "Off-peak price", + "night_start": "Off-peak from", + "night_end": "Off-peak until", + "night_weekend": "Off-peak all weekend", "feed_in_price": "Feed-in price sensor", "feed_in_fixed": "Fixed feed-in price", "net_metering_until": "Net metering until" }, "data_description": { - "price": "Your dynamic tariff sensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh and EUR/MWh are converted automatically.", + "price": "Your dynamic tariff sensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh and EUR/MWh are converted automatically. Leave empty if you have a fixed tariff.", "price_surcharge": "Only for raw market prices (EPEX, Nordpool): energy tax + supplier fee in EUR/kWh. Leave empty for all-in prices such as Tibber.", "vat": "Only for raw market prices: applied to price + extra cost. 21 in the Netherlands. Leave empty for all-in prices.", + "price_fixed": "All-in price per kWh. On a single-rate contract, enter your price here and leave off-peak empty; then also fill in a feed-in price and grid power, because without them the card can only show charging losses. Also used when the price sensor is unavailable.", + "price_night": "Only for a peak/off-peak contract: the all-in off-peak price per kWh.", + "night_start": "When the off-peak price starts.", + "night_end": "When the off-peak price ends.", + "night_weekend": "Many peak/off-peak contracts charge the off-peak price from Friday night until Monday morning.", "feed_in_price": "What you get per exported kWh. On many dynamic contracts that is the same market price sensor as above. Only used together with grid power: then solar that goes into the battery costs what you would have been paid for exporting it, and grid charging costs the import price.", "feed_in_fixed": "Use this instead of a sensor when you get a fixed amount per exported kWh (0 if you get nothing).", "net_metering_until": "Until this day an exported kWh is worth the full import price (net metering), so the feed-in price is ignored. Set it once and the switch happens by itself. Leave empty if you have no net metering." @@ -67,7 +77,7 @@ "data_description": { "standby_power": "What the battery uses itself (inverter, BMS) that its power sensors don't show. Leave empty if your charge sensor already includes it.", "wear_cost": "Degradation in EUR per kWh delivered: purchase cost ÷ (capacity × rated cycles). Counted once per kWh.", - "battery_cost": "Adds a payback sensor.", + "battery_cost": "Adds a payback sensor with the expected payback date.", "install_date": "Fills in the totals from Home Assistant's history, starting this day. Detailed history usually covers the last 10 days; before that, hourly averages are used where your sensors keep long-term statistics. Replaces the current totals." } } @@ -75,7 +85,8 @@ } }, "error": { - "same_sensor": "Charge and discharge power must be different sensors." + "same_sensor": "Charge and discharge power must be different sensors.", + "no_price": "Pick a price sensor or enter a peak price." }, "abort": { "single_instance_allowed": "Battery ROI is already set up. Use Configure to change sensors." @@ -111,17 +122,27 @@ "prices": { "name": "Prices", "data": { - "price": "Electricity price (import)", + "price": "Electricity price sensor (dynamic)", "price_surcharge": "Extra cost per kWh, excluding VAT", "vat": "VAT", + "price_fixed": "Peak price", + "price_night": "Off-peak price", + "night_start": "Off-peak from", + "night_end": "Off-peak until", + "night_weekend": "Off-peak all weekend", "feed_in_price": "Feed-in price sensor", "feed_in_fixed": "Fixed feed-in price", "net_metering_until": "Net metering until" }, "data_description": { - "price": "Your dynamic tariff sensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh and EUR/MWh are converted automatically.", + "price": "Your dynamic tariff sensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh and EUR/MWh are converted automatically. Leave empty if you have a fixed tariff.", "price_surcharge": "Only for raw market prices (EPEX, Nordpool): energy tax + supplier fee in EUR/kWh. Leave empty for all-in prices such as Tibber.", "vat": "Only for raw market prices: applied to price + extra cost. 21 in the Netherlands. Leave empty for all-in prices.", + "price_fixed": "All-in price per kWh. On a single-rate contract, enter your price here and leave off-peak empty; then also fill in a feed-in price and grid power, because without them the card can only show charging losses. Also used when the price sensor is unavailable.", + "price_night": "Only for a peak/off-peak contract: the all-in off-peak price per kWh.", + "night_start": "When the off-peak price starts.", + "night_end": "When the off-peak price ends.", + "night_weekend": "Many peak/off-peak contracts charge the off-peak price from Friday night until Monday morning.", "feed_in_price": "What you get per exported kWh. On many dynamic contracts that is the same market price sensor as above. Only used together with grid power: then solar that goes into the battery costs what you would have been paid for exporting it, and grid charging costs the import price.", "feed_in_fixed": "Use this instead of a sensor when you get a fixed amount per exported kWh (0 if you get nothing).", "net_metering_until": "Until this day an exported kWh is worth the full import price (net metering), so the feed-in price is ignored. Set it once and the switch happens by itself. Leave empty if you have no net metering." @@ -149,7 +170,7 @@ "data_description": { "standby_power": "What the battery uses itself (inverter, BMS) that its power sensors don't show. Leave empty if your charge sensor already includes it.", "wear_cost": "Degradation in EUR per kWh delivered: purchase cost ÷ (capacity × rated cycles). Counted once per kWh.", - "battery_cost": "Adds a payback sensor.", + "battery_cost": "Adds a payback sensor with the expected payback date.", "install_date": "Fills in the totals from Home Assistant's history, starting this day. Detailed history usually covers the last 10 days; before that, hourly averages are used where your sensors keep long-term statistics. Replaces the current totals." } } @@ -157,7 +178,8 @@ } }, "error": { - "same_sensor": "Charge and discharge power must be different sensors." + "same_sensor": "Charge and discharge power must be different sensors.", + "no_price": "Pick a price sensor or enter a peak price." } }, "selector": { diff --git a/custom_components/battery_roi/translations/en.json b/custom_components/battery_roi/translations/en.json index dbaf915..956983b 100644 --- a/custom_components/battery_roi/translations/en.json +++ b/custom_components/battery_roi/translations/en.json @@ -29,17 +29,27 @@ "prices": { "name": "Prices", "data": { - "price": "Electricity price (import)", + "price": "Electricity price sensor (dynamic)", "price_surcharge": "Extra cost per kWh, excluding VAT", "vat": "VAT", + "price_fixed": "Peak price", + "price_night": "Off-peak price", + "night_start": "Off-peak from", + "night_end": "Off-peak until", + "night_weekend": "Off-peak all weekend", "feed_in_price": "Feed-in price sensor", "feed_in_fixed": "Fixed feed-in price", "net_metering_until": "Net metering until" }, "data_description": { - "price": "Your dynamic tariff sensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh and EUR/MWh are converted automatically.", + "price": "Your dynamic tariff sensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh and EUR/MWh are converted automatically. Leave empty if you have a fixed tariff.", "price_surcharge": "Only for raw market prices (EPEX, Nordpool): energy tax + supplier fee in EUR/kWh. Leave empty for all-in prices such as Tibber.", "vat": "Only for raw market prices: applied to price + extra cost. 21 in the Netherlands. Leave empty for all-in prices.", + "price_fixed": "All-in price per kWh. On a single-rate contract, enter your price here and leave off-peak empty; then also fill in a feed-in price and grid power, because without them the card can only show charging losses. Also used when the price sensor is unavailable.", + "price_night": "Only for a peak/off-peak contract: the all-in off-peak price per kWh.", + "night_start": "When the off-peak price starts.", + "night_end": "When the off-peak price ends.", + "night_weekend": "Many peak/off-peak contracts charge the off-peak price from Friday night until Monday morning.", "feed_in_price": "What you get per exported kWh. On many dynamic contracts that is the same market price sensor as above. Only used together with grid power: then solar that goes into the battery costs what you would have been paid for exporting it, and grid charging costs the import price.", "feed_in_fixed": "Use this instead of a sensor when you get a fixed amount per exported kWh (0 if you get nothing).", "net_metering_until": "Until this day an exported kWh is worth the full import price (net metering), so the feed-in price is ignored. Set it once and the switch happens by itself. Leave empty if you have no net metering." @@ -67,7 +77,7 @@ "data_description": { "standby_power": "What the battery uses itself (inverter, BMS) that its power sensors don't show. Leave empty if your charge sensor already includes it.", "wear_cost": "Degradation in EUR per kWh delivered: purchase cost ÷ (capacity × rated cycles). Counted once per kWh.", - "battery_cost": "Adds a payback sensor.", + "battery_cost": "Adds a payback sensor with the expected payback date.", "install_date": "Fills in the totals from Home Assistant's history, starting this day. Detailed history usually covers the last 10 days; before that, hourly averages are used where your sensors keep long-term statistics. Replaces the current totals." } } @@ -75,7 +85,8 @@ } }, "error": { - "same_sensor": "Charge and discharge power must be different sensors." + "same_sensor": "Charge and discharge power must be different sensors.", + "no_price": "Pick a price sensor or enter a peak price." }, "abort": { "single_instance_allowed": "Battery ROI is already set up. Use Configure to change sensors." @@ -111,17 +122,27 @@ "prices": { "name": "Prices", "data": { - "price": "Electricity price (import)", + "price": "Electricity price sensor (dynamic)", "price_surcharge": "Extra cost per kWh, excluding VAT", "vat": "VAT", + "price_fixed": "Peak price", + "price_night": "Off-peak price", + "night_start": "Off-peak from", + "night_end": "Off-peak until", + "night_weekend": "Off-peak all weekend", "feed_in_price": "Feed-in price sensor", "feed_in_fixed": "Fixed feed-in price", "net_metering_until": "Net metering until" }, "data_description": { - "price": "Your dynamic tariff sensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh and EUR/MWh are converted automatically.", + "price": "Your dynamic tariff sensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh and EUR/MWh are converted automatically. Leave empty if you have a fixed tariff.", "price_surcharge": "Only for raw market prices (EPEX, Nordpool): energy tax + supplier fee in EUR/kWh. Leave empty for all-in prices such as Tibber.", "vat": "Only for raw market prices: applied to price + extra cost. 21 in the Netherlands. Leave empty for all-in prices.", + "price_fixed": "All-in price per kWh. On a single-rate contract, enter your price here and leave off-peak empty; then also fill in a feed-in price and grid power, because without them the card can only show charging losses. Also used when the price sensor is unavailable.", + "price_night": "Only for a peak/off-peak contract: the all-in off-peak price per kWh.", + "night_start": "When the off-peak price starts.", + "night_end": "When the off-peak price ends.", + "night_weekend": "Many peak/off-peak contracts charge the off-peak price from Friday night until Monday morning.", "feed_in_price": "What you get per exported kWh. On many dynamic contracts that is the same market price sensor as above. Only used together with grid power: then solar that goes into the battery costs what you would have been paid for exporting it, and grid charging costs the import price.", "feed_in_fixed": "Use this instead of a sensor when you get a fixed amount per exported kWh (0 if you get nothing).", "net_metering_until": "Until this day an exported kWh is worth the full import price (net metering), so the feed-in price is ignored. Set it once and the switch happens by itself. Leave empty if you have no net metering." @@ -149,7 +170,7 @@ "data_description": { "standby_power": "What the battery uses itself (inverter, BMS) that its power sensors don't show. Leave empty if your charge sensor already includes it.", "wear_cost": "Degradation in EUR per kWh delivered: purchase cost ÷ (capacity × rated cycles). Counted once per kWh.", - "battery_cost": "Adds a payback sensor.", + "battery_cost": "Adds a payback sensor with the expected payback date.", "install_date": "Fills in the totals from Home Assistant's history, starting this day. Detailed history usually covers the last 10 days; before that, hourly averages are used where your sensors keep long-term statistics. Replaces the current totals." } } @@ -157,7 +178,8 @@ } }, "error": { - "same_sensor": "Charge and discharge power must be different sensors." + "same_sensor": "Charge and discharge power must be different sensors.", + "no_price": "Pick a price sensor or enter a peak price." } }, "selector": { diff --git a/custom_components/battery_roi/translations/nl.json b/custom_components/battery_roi/translations/nl.json index e80d5fb..b1e1b85 100644 --- a/custom_components/battery_roi/translations/nl.json +++ b/custom_components/battery_roi/translations/nl.json @@ -29,17 +29,27 @@ "prices": { "name": "Prijzen", "data": { - "price": "Stroomprijs (afname)", + "price": "Stroomprijs-sensor (dynamisch)", "price_surcharge": "Extra kosten per kWh, exclusief btw", "vat": "Btw", + "price_fixed": "Piekprijs", + "price_night": "Dalprijs", + "night_start": "Dalprijs vanaf", + "night_end": "Dalprijs tot", + "night_weekend": "Dalprijs het hele weekend", "feed_in_price": "Terugleverprijs-sensor", "feed_in_fixed": "Vaste terugleverprijs", "net_metering_until": "Salderen tot" }, "data_description": { - "price": "Je dynamische tariefsensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh en EUR/MWh worden automatisch omgerekend.", + "price": "Je dynamische tariefsensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh en EUR/MWh worden automatisch omgerekend. Leeg laten als je een vast tarief hebt.", "price_surcharge": "Alleen voor kale marktprijzen (EPEX, Nordpool): energiebelasting + inkoopvergoeding in EUR/kWh. Leeg laten bij all-in prijzen zoals Tibber.", "vat": "Alleen voor kale marktprijzen: over prijs + extra kosten. In Nederland 21. Leeg laten bij all-in prijzen.", + "price_fixed": "All-in prijs per kWh. Bij een enkel tarief vul je hier je prijs in en laat je de dalprijs leeg; vul dan ook een terugleverprijs en netvermogen in, want zonder die twee kan de kaart alleen laadverliezen tonen. Wordt ook gebruikt als de prijssensor niet beschikbaar is.", + "price_night": "Alleen bij een piek/dal-contract: de all-in dalprijs per kWh.", + "night_start": "Wanneer de dalprijs begint.", + "night_end": "Wanneer de dalprijs eindigt.", + "night_weekend": "Veel piek/dal-contracten rekenen de dalprijs van vrijdagavond tot maandagochtend.", "feed_in_price": "Wat je per teruggeleverde kWh krijgt. Bij veel dynamische contracten is dat dezelfde marktprijssensor als hierboven. Werkt alleen samen met netvermogen: zonnestroom die de batterij in gaat kost dan wat je voor teruglevering had gekregen, laden van het net kost de inkoopprijs.", "feed_in_fixed": "Gebruik dit in plaats van een sensor als je een vast bedrag per teruggeleverde kWh krijgt (0 als je niets krijgt).", "net_metering_until": "Tot deze dag is een teruggeleverde kWh de volle stroomprijs waard (salderen), dus wordt de terugleverprijs genegeerd. Stel het één keer in, daarna schakelt het vanzelf om. Laat leeg als je niet saldeert." @@ -67,7 +77,7 @@ "data_description": { "standby_power": "Wat de batterij zelf gebruikt (omvormer, BMS) en niet in de vermogenssensoren zit. Leeg laten als je laadsensor het al meetelt.", "wear_cost": "Degradatie in EUR per geleverde kWh: aanschafprijs ÷ (capaciteit × aantal cycli). Eén keer per kWh geteld.", - "battery_cost": "Voegt een terugverdientijd-sensor toe.", + "battery_cost": "Voegt een terugverdien-sensor toe met de verwachte datum.", "install_date": "Vult de totalen in vanuit de geschiedenis van Home Assistant, vanaf deze dag. Gedetailleerde geschiedenis gaat meestal 10 dagen terug; daarvoor worden uurgemiddelden gebruikt als je sensoren langetermijnstatistieken bijhouden. Vervangt de huidige totalen." } } @@ -75,7 +85,8 @@ } }, "error": { - "same_sensor": "Laad- en ontlaadvermogen moeten verschillende sensoren zijn." + "same_sensor": "Laad- en ontlaadvermogen moeten verschillende sensoren zijn.", + "no_price": "Kies een prijssensor of vul een piekprijs in." }, "abort": { "single_instance_allowed": "Battery ROI is al ingesteld. Gebruik Configureren om sensoren te wijzigen." @@ -111,17 +122,27 @@ "prices": { "name": "Prijzen", "data": { - "price": "Stroomprijs (afname)", + "price": "Stroomprijs-sensor (dynamisch)", "price_surcharge": "Extra kosten per kWh, exclusief btw", "vat": "Btw", + "price_fixed": "Piekprijs", + "price_night": "Dalprijs", + "night_start": "Dalprijs vanaf", + "night_end": "Dalprijs tot", + "night_weekend": "Dalprijs het hele weekend", "feed_in_price": "Terugleverprijs-sensor", "feed_in_fixed": "Vaste terugleverprijs", "net_metering_until": "Salderen tot" }, "data_description": { - "price": "Je dynamische tariefsensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh en EUR/MWh worden automatisch omgerekend.", + "price": "Je dynamische tariefsensor: EPEX, Nordpool, ENTSO-e, Tibber, Frank Energie, Zonneplan… EUR/kWh, ct/kWh en EUR/MWh worden automatisch omgerekend. Leeg laten als je een vast tarief hebt.", "price_surcharge": "Alleen voor kale marktprijzen (EPEX, Nordpool): energiebelasting + inkoopvergoeding in EUR/kWh. Leeg laten bij all-in prijzen zoals Tibber.", "vat": "Alleen voor kale marktprijzen: over prijs + extra kosten. In Nederland 21. Leeg laten bij all-in prijzen.", + "price_fixed": "All-in prijs per kWh. Bij een enkel tarief vul je hier je prijs in en laat je de dalprijs leeg; vul dan ook een terugleverprijs en netvermogen in, want zonder die twee kan de kaart alleen laadverliezen tonen. Wordt ook gebruikt als de prijssensor niet beschikbaar is.", + "price_night": "Alleen bij een piek/dal-contract: de all-in dalprijs per kWh.", + "night_start": "Wanneer de dalprijs begint.", + "night_end": "Wanneer de dalprijs eindigt.", + "night_weekend": "Veel piek/dal-contracten rekenen de dalprijs van vrijdagavond tot maandagochtend.", "feed_in_price": "Wat je per teruggeleverde kWh krijgt. Bij veel dynamische contracten is dat dezelfde marktprijssensor als hierboven. Werkt alleen samen met netvermogen: zonnestroom die de batterij in gaat kost dan wat je voor teruglevering had gekregen, laden van het net kost de inkoopprijs.", "feed_in_fixed": "Gebruik dit in plaats van een sensor als je een vast bedrag per teruggeleverde kWh krijgt (0 als je niets krijgt).", "net_metering_until": "Tot deze dag is een teruggeleverde kWh de volle stroomprijs waard (salderen), dus wordt de terugleverprijs genegeerd. Stel het één keer in, daarna schakelt het vanzelf om. Laat leeg als je niet saldeert." @@ -149,7 +170,7 @@ "data_description": { "standby_power": "Wat de batterij zelf gebruikt (omvormer, BMS) en niet in de vermogenssensoren zit. Leeg laten als je laadsensor het al meetelt.", "wear_cost": "Degradatie in EUR per geleverde kWh: aanschafprijs ÷ (capaciteit × aantal cycli). Eén keer per kWh geteld.", - "battery_cost": "Voegt een terugverdientijd-sensor toe.", + "battery_cost": "Voegt een terugverdien-sensor toe met de verwachte datum.", "install_date": "Vult de totalen in vanuit de geschiedenis van Home Assistant, vanaf deze dag. Gedetailleerde geschiedenis gaat meestal 10 dagen terug; daarvoor worden uurgemiddelden gebruikt als je sensoren langetermijnstatistieken bijhouden. Vervangt de huidige totalen." } } @@ -157,7 +178,8 @@ } }, "error": { - "same_sensor": "Laad- en ontlaadvermogen moeten verschillende sensoren zijn." + "same_sensor": "Laad- en ontlaadvermogen moeten verschillende sensoren zijn.", + "no_price": "Kies een prijssensor of vul een piekprijs in." } }, "selector": { diff --git a/docs/card-dark.png b/docs/card-dark.png index dfd5171..748a59f 100644 Binary files a/docs/card-dark.png and b/docs/card-dark.png differ diff --git a/docs/card-light.png b/docs/card-light.png index 3d1e990..7245565 100644 Binary files a/docs/card-light.png and b/docs/card-light.png differ diff --git a/tests/test_integration.py b/tests/test_integration.py index 50945fb..645c409 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -188,3 +188,54 @@ async def test_net_metering_ignores_feed_in_until_its_end(hass: HomeAssistant, f hass.states.async_set("sensor.grid", "-2001", {"unit_of_measurement": "W"}) await hass.async_block_till_done() assert float(hass.states.get("sensor.battery_roi_rate").state) == pytest.approx(-0.05) + + +async def test_fixed_day_and_night_price_without_sensor(hass: HomeAssistant, freezer) -> None: + """A fixed day/night contract works without any price sensor.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2026-09-25 12:00:00+02:00") # a Friday + hass.states.async_set("sensor.ecoflow_power", "-1000", {"unit_of_measurement": "W"}) + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + no_price = {k: v for k, v in INPUT.items() if k != "price"} + result = await hass.config_entries.flow.async_configure(result["flow_id"], _nest(no_price)) + assert result["errors"] == {"base": "no_price"} + + await hass.config_entries.flow.async_configure( + result["flow_id"], + _nest({**no_price, "price_fixed": 0.30, "price_night": 0.20, "night_start": "23:00:00", "night_end": "07:00:00"}), + ) + await hass.async_block_till_done() + assert float(hass.states.get("sensor.battery_roi_rate").state) == pytest.approx(-0.30) + + freezer.move_to("2026-09-25 23:30:00+02:00") + hass.states.async_set("sensor.ecoflow_power", "-1001", {"unit_of_measurement": "W"}) + await hass.async_block_till_done() + assert float(hass.states.get("sensor.battery_roi_rate").state) == pytest.approx(-0.2002) + + # Saturday noon: weekends are off-peak by default. + freezer.move_to("2026-09-26 12:00:00+02:00") + hass.states.async_set("sensor.ecoflow_power", "-1000", {"unit_of_measurement": "W"}) + await hass.async_block_till_done() + assert float(hass.states.get("sensor.battery_roi_rate").state) == pytest.approx(-0.20) + + +async def test_fixed_price_covers_an_unavailable_sensor(hass: HomeAssistant, freezer) -> None: + """The dynamic sensor wins; the fixed price only fills in when it has no value.""" + hass.states.async_set("sensor.ecoflow_power", "-1000", {"unit_of_measurement": "W"}) + hass.states.async_set("sensor.tariff", "0.10", {"unit_of_measurement": "EUR/kWh"}) + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) + await hass.config_entries.flow.async_configure(result["flow_id"], _nest({**INPUT, "price_fixed": 0.30})) + await hass.async_block_till_done() + assert float(hass.states.get("sensor.battery_roi_rate").state) == pytest.approx(-0.10) + + hass.states.async_set("sensor.tariff", "unavailable") + await hass.async_block_till_done() + assert float(hass.states.get("sensor.battery_roi_rate").state) == pytest.approx(-0.30) + + +def test_paid_back_on_is_a_date(freezer) -> None: + from custom_components.battery_roi.sensor import _paid_back_on + + freezer.move_to("2026-09-26 12:00:00+02:00") + assert _paid_back_on(None) is None + assert _paid_back_on(2.0)[:7] == "2028-09"