Skip to content

Commit e5891b4

Browse files
feat: get price_euro_per_wh_accu from home-assistant/openhab
1 parent 5fcc99c commit e5891b4

File tree

5 files changed

+262
-3
lines changed

5 files changed

+262
-3
lines changed

src/CONFIG_README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,18 @@ A default config file will be created with the first start, if there is no `conf
200200
- **`price_euro_per_wh_accu`**:
201201
Price for battery in €/Wh - can be used to shift the result over the day according to the available energy (more details follow).
202202

203+
- **`battery.price_euro_per_wh_source`**:
204+
Determines where the battery energy cost should be read from.
205+
- `config` *(default)*: Use the static `price_euro_per_wh_accu` value.
206+
- `homeassistant`: Fetch the price from a Home Assistant entity defined via `battery.price_euro_per_wh_sensor`. Requires `battery.url` and `battery.access_token`.
207+
- `openhab`: Fetch the price from an OpenHAB item defined via `battery.price_euro_per_wh_sensor`. Requires `battery.url`.
208+
209+
- **`battery.price_euro_per_wh_sensor`**:
210+
Sensor/item identifier that exposes the battery price in €/Wh.
211+
- For Home Assistant: Entity ID (e.g., `sensor.battery_price`).
212+
- For OpenHAB: Item name (e.g., `BatteryPrice`).
213+
Only used when `battery.price_euro_per_wh_source` is set to `homeassistant` or `openhab`.
214+
203215
- **`battery.charging_curve_enabled`**:
204216
Enables or disables the dynamic charging curve for the battery.
205217
- `true`: The system will automatically reduce the maximum charging power as the battery SOC increases, helping to protect battery health and optimize efficiency.
@@ -471,6 +483,8 @@ battery:
471483
min_soc_percentage: 5 # URL for battery soc in %
472484
max_soc_percentage: 100 # URL for battery soc in %
473485
price_euro_per_wh_accu: 0 # price for battery in €/Wh
486+
price_euro_per_wh_source: config # use static config value or fetch from homeassistant
487+
price_euro_per_wh_sensor: "" # Home Assistant entity (e.g. sensor.battery_price) providing €/Wh
474488
charging_curve_enabled: true # enable dynamic charging curve for battery
475489
# List of PV forecast source configuration
476490
pv_forecast_source:
@@ -548,6 +562,7 @@ battery:
548562
min_soc_percentage: 5 # URL for battery soc in %
549563
max_soc_percentage: 100 # URL for battery soc in %
550564
price_euro_per_wh_accu: 0 # price for battery in €/Wh
565+
price_euro_per_wh_source: config # use static config value or fetch from homeassistant
551566
charging_curve_enabled: true # enable dynamic charging curve for battery
552567
# List of PV forecast source configuration
553568
pv_forecast_source:

src/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ def create_default_config(self):
8989
"min_soc_percentage": 5,
9090
"max_soc_percentage": 100,
9191
"price_euro_per_wh_accu": 0.0, # price for battery in euro/Wh
92+
"price_euro_per_wh_source": "config", # config (static), homeassistant, or openhab
93+
"price_euro_per_wh_sensor": "", # sensor/item providing battery energy cost in €/Wh
9294
"charging_curve_enabled": True, # enable charging curve
9395
}
9496
),
@@ -281,6 +283,14 @@ def create_default_config(self):
281283
config["battery"].yaml_add_eol_comment(
282284
"price for battery in euro/Wh - default: 0.0", "price_euro_per_wh_accu"
283285
)
286+
config["battery"].yaml_add_eol_comment(
287+
"source for battery price: config (static), homeassistant, or openhab",
288+
"price_euro_per_wh_source",
289+
)
290+
config["battery"].yaml_add_eol_comment(
291+
"sensor/item providing the battery price (€/Wh) - HA entity or OpenHAB item",
292+
"price_euro_per_wh_sensor",
293+
)
284294
config["battery"].yaml_add_eol_comment(
285295
"enabling charging curve for controlled charging power"
286296
+ " according to the SOC (default: true)",

src/eos_connect.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,9 +493,7 @@ def get_ems_data(dst_change_detected):
493493
"pv_prognose_wh": pv_prognose_wh,
494494
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
495495
"einspeiseverguetung_euro_pro_wh": einspeiseverguetung_euro_pro_wh,
496-
"preis_euro_pro_wh_akku": config_manager.config["battery"][
497-
"price_euro_per_wh_accu"
498-
],
496+
"preis_euro_pro_wh_akku": battery_interface.get_price_euro_per_wh(),
499497
"gesamtlast": gesamtlast,
500498
}
501499

src/interfaces/battery_interface.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def __init__(self, config, on_bat_max_changed=None):
7777
self.on_bat_max_changed = on_bat_max_changed
7878
self.min_soc_set = config.get("min_soc_percentage", 0)
7979
self.max_soc_set = config.get("max_soc_percentage", 100)
80+
self.price_euro_per_wh = float(config.get("price_euro_per_wh_accu", 0.0))
81+
self.price_source = config.get("price_euro_per_wh_source", "config")
82+
self.price_sensor = config.get("price_euro_per_wh_sensor", "")
8083

8184
self.soc_fail_count = 0
8285

@@ -192,6 +195,109 @@ def __battery_request_current_soc(self):
192195
)
193196
return self.current_soc
194197

198+
def __fetch_price_data_from_openhab(self):
199+
"""
200+
Fetch the current battery energy price from an OpenHAB item.
201+
202+
Returns:
203+
float: Battery energy cost in €/Wh provided by the configured item.
204+
"""
205+
if not self.price_sensor:
206+
raise ValueError("price_euro_per_wh_sensor must be configured for OpenHAB.")
207+
208+
logger.debug("[BATTERY-IF] getting price from openhab ...")
209+
openhab_url = self.url + "/rest/items/" + self.price_sensor
210+
try:
211+
response = requests.get(openhab_url, timeout=6)
212+
response.raise_for_status()
213+
data = response.json()
214+
raw_state = str(data["state"]).strip()
215+
# Take only the first part before any space (handles "0.0001", "0.0001 €/Wh", etc.)
216+
cleaned_value = raw_state.split()[0]
217+
price = float(cleaned_value)
218+
logger.debug("[BATTERY-IF] Fetched price from OpenHAB: %s €/Wh", price)
219+
return price
220+
except requests.exceptions.Timeout:
221+
raise requests.exceptions.Timeout(
222+
"Request timed out while fetching price from OpenHAB"
223+
)
224+
except requests.exceptions.RequestException as e:
225+
raise requests.exceptions.RequestException(
226+
f"Error fetching price from OpenHAB: {e}"
227+
)
228+
229+
def __fetch_price_data_from_homeassistant(self):
230+
"""
231+
Fetch the current battery energy price from a Home Assistant sensor.
232+
233+
Returns:
234+
float: Battery energy cost in €/Wh provided by the configured sensor.
235+
"""
236+
if not self.price_sensor:
237+
raise ValueError(
238+
"price_euro_per_wh_sensor must be configured for Home Assistant."
239+
)
240+
241+
logger.debug("[BATTERY-IF] getting price from homeassistant ...")
242+
homeassistant_url = f"{self.url}/api/states/{self.price_sensor}"
243+
headers = {
244+
"Authorization": f"Bearer {self.access_token}",
245+
"Content-Type": "application/json",
246+
}
247+
response = requests.get(homeassistant_url, headers=headers, timeout=6)
248+
response.raise_for_status()
249+
entity_data = response.json()
250+
price = float(entity_data["state"])
251+
logger.debug("[BATTERY-IF] Fetched price from Home Assistant: %s €/Wh", price)
252+
return price
253+
254+
def __update_price_euro_per_wh(self):
255+
"""
256+
Update the battery price from the configured source if needed.
257+
"""
258+
if self.price_source == "config":
259+
return self.price_euro_per_wh
260+
261+
source_name = self.price_source.upper()
262+
try:
263+
if self.price_source == "homeassistant":
264+
latest_price = self.__fetch_price_data_from_homeassistant()
265+
elif self.price_source == "openhab":
266+
latest_price = self.__fetch_price_data_from_openhab()
267+
else:
268+
logger.warning(
269+
"[BATTERY-IF] Unknown price source '%s'. Keeping last value %s.",
270+
self.price_source,
271+
self.price_euro_per_wh,
272+
)
273+
return self.price_euro_per_wh
274+
except requests.exceptions.Timeout:
275+
logger.warning(
276+
"[BATTERY-IF] %s - Request timed out while fetching "
277+
+ "price_euro_per_wh_accu. Keeping last value %s.",
278+
source_name,
279+
self.price_euro_per_wh,
280+
)
281+
return self.price_euro_per_wh
282+
except (requests.exceptions.RequestException, ValueError, KeyError) as exc:
283+
logger.warning(
284+
"[BATTERY-IF] %s - Error fetching price sensor data: %s. "
285+
+ "Keeping last value %s.",
286+
source_name,
287+
exc,
288+
self.price_euro_per_wh,
289+
)
290+
return self.price_euro_per_wh
291+
292+
self.price_euro_per_wh = latest_price
293+
logger.debug(
294+
"[BATTERY-IF] Updated price_euro_per_wh_accu from %s sensor %s: %s",
295+
self.price_source,
296+
self.price_sensor,
297+
self.price_euro_per_wh,
298+
)
299+
return self.price_euro_per_wh
300+
195301
def _handle_soc_error(self, source, error, last_soc):
196302
self.soc_fail_count += 1
197303
if self.soc_fail_count < 5:
@@ -236,6 +342,12 @@ def get_min_soc(self):
236342
"""
237343
return self.min_soc_set
238344

345+
def get_price_euro_per_wh(self):
346+
"""
347+
Returns the current battery price in €/Wh.
348+
"""
349+
return self.price_euro_per_wh
350+
239351
def set_min_soc(self, min_soc):
240352
"""
241353
Sets the minimum state of charge (SOC) percentage of the battery.
@@ -402,6 +514,7 @@ def _update_state_loop(self):
402514
),
403515
)
404516
self.__get_max_charge_power_dyn()
517+
self.__update_price_euro_per_wh()
405518

406519
except (requests.exceptions.RequestException, ValueError, KeyError) as e:
407520
logger.error("[BATTERY-IF] Error while updating state: %s", e)

tests/interfaces/test_battery_interface.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from unittest.mock import patch, MagicMock
99
import pytest
10+
import requests
1011
from src.interfaces.battery_interface import BatteryInterface
1112

1213
# Accessing protected members is fine in white-box tests.
@@ -28,6 +29,9 @@ def default_config():
2829
"max_soc_percentage": 90,
2930
"charging_curve_enabled": True,
3031
"discharge_efficiency": 1.0,
32+
"price_euro_per_wh_accu": 0.0,
33+
"price_euro_per_wh_source": "config",
34+
"price_euro_per_wh_sensor": "",
3135
}
3236

3337

@@ -106,6 +110,125 @@ def test_homeassistant_fetch_success(default_config):
106110
assert soc == 55.0
107111

108112

113+
def test_homeassistant_price_sensor_success(default_config):
114+
"""
115+
Ensure the Home Assistant price sensor value is fetched and stored.
116+
"""
117+
test_config = default_config.copy()
118+
test_config.update(
119+
{
120+
"url": "http://fake",
121+
"access_token": "token",
122+
"price_euro_per_wh_source": "homeassistant",
123+
"price_euro_per_wh_sensor": "sensor.accu_price",
124+
}
125+
)
126+
with patch("src.interfaces.battery_interface.requests.get") as mock_get:
127+
mock_resp = MagicMock()
128+
mock_resp.json.return_value = {"state": "0.002"}
129+
mock_resp.raise_for_status.return_value = None
130+
mock_get.return_value = mock_resp
131+
bi = BatteryInterface(test_config)
132+
# Ensure manual update works and the getter reflects the sensor value
133+
bi._BatteryInterface__update_price_euro_per_wh()
134+
assert bi.get_price_euro_per_wh() == pytest.approx(0.002)
135+
bi.shutdown()
136+
137+
138+
def test_homeassistant_price_sensor_failure_keeps_last_value(default_config):
139+
"""
140+
Ensure failing sensor updates keep the last configured price.
141+
"""
142+
test_config = default_config.copy()
143+
test_config.update(
144+
{
145+
"url": "http://fake",
146+
"access_token": "token",
147+
"price_euro_per_wh_source": "homeassistant",
148+
"price_euro_per_wh_sensor": "sensor.accu_price",
149+
"price_euro_per_wh_accu": 0.001,
150+
}
151+
)
152+
with patch(
153+
"src.interfaces.battery_interface.requests.get",
154+
side_effect=requests.exceptions.RequestException("boom"),
155+
):
156+
bi = BatteryInterface(test_config)
157+
bi._BatteryInterface__update_price_euro_per_wh()
158+
assert bi.get_price_euro_per_wh() == pytest.approx(0.001)
159+
bi.shutdown()
160+
161+
162+
def test_openhab_price_sensor_success(default_config):
163+
"""
164+
Ensure the OpenHAB price item value is fetched and stored.
165+
"""
166+
test_config = default_config.copy()
167+
test_config.update(
168+
{
169+
"url": "http://fake",
170+
"price_euro_per_wh_source": "openhab",
171+
"price_euro_per_wh_sensor": "BatteryPrice",
172+
}
173+
)
174+
with patch("src.interfaces.battery_interface.requests.get") as mock_get:
175+
mock_resp = MagicMock()
176+
mock_resp.json.return_value = {"state": "0.00015"}
177+
mock_resp.raise_for_status.return_value = None
178+
mock_get.return_value = mock_resp
179+
bi = BatteryInterface(test_config)
180+
# Ensure manual update works and the getter reflects the item value
181+
bi._BatteryInterface__update_price_euro_per_wh()
182+
assert bi.get_price_euro_per_wh() == pytest.approx(0.00015)
183+
bi.shutdown()
184+
185+
186+
def test_openhab_price_sensor_with_unit_success(default_config):
187+
"""
188+
Ensure OpenHAB price item with unit (e.g., "0.00015 €/Wh") is parsed correctly.
189+
"""
190+
test_config = default_config.copy()
191+
test_config.update(
192+
{
193+
"url": "http://fake",
194+
"price_euro_per_wh_source": "openhab",
195+
"price_euro_per_wh_sensor": "BatteryPrice",
196+
}
197+
)
198+
with patch("src.interfaces.battery_interface.requests.get") as mock_get:
199+
mock_resp = MagicMock()
200+
mock_resp.json.return_value = {"state": "0.00015 €/Wh"}
201+
mock_resp.raise_for_status.return_value = None
202+
mock_get.return_value = mock_resp
203+
bi = BatteryInterface(test_config)
204+
bi._BatteryInterface__update_price_euro_per_wh()
205+
assert bi.get_price_euro_per_wh() == pytest.approx(0.00015)
206+
bi.shutdown()
207+
208+
209+
def test_openhab_price_sensor_failure_keeps_last_value(default_config):
210+
"""
211+
Ensure failing OpenHAB item updates keep the last configured price.
212+
"""
213+
test_config = default_config.copy()
214+
test_config.update(
215+
{
216+
"url": "http://fake",
217+
"price_euro_per_wh_source": "openhab",
218+
"price_euro_per_wh_sensor": "BatteryPrice",
219+
"price_euro_per_wh_accu": 0.0001,
220+
}
221+
)
222+
with patch(
223+
"src.interfaces.battery_interface.requests.get",
224+
side_effect=requests.exceptions.RequestException("boom"),
225+
):
226+
bi = BatteryInterface(test_config)
227+
bi._BatteryInterface__update_price_euro_per_wh()
228+
assert bi.get_price_euro_per_wh() == pytest.approx(0.0001)
229+
bi.shutdown()
230+
231+
109232
def test_soc_error_handling(default_config):
110233
"""
111234
Test SOC error handling and fail count reset.

0 commit comments

Comments
 (0)