From d1b06fab1e5dc9ee55b138e203933d4efab5a5ac Mon Sep 17 00:00:00 2001
From: ohAnd <15704728+ohAnd@users.noreply.github.com>
Date: Sat, 12 Apr 2025 22:03:29 +0200
Subject: [PATCH 1/3] feat: Enhance price management and configuration
- Updated CONFIG_README.md to include new configuration options for feed-in price and negative price switch.
- Modified config.py to add feed-in price and negative price switch to the price configuration.
- Introduced price_interface.py to manage electricity price data retrieval and processing from Akkudoktor and Tibber APIs.
- Refactored eos_connect.py to utilize the new PriceInterface for fetching current prices and feed-in prices.
- Implemented background update service in battery_interface.py to periodically fetch state of charge (SOC) data.
- Updated load_interface.py to improve error handling and data processing.
- Commented out debug logs in base_control.py and battery_interface.py to reduce log verbosity.
---
src/CONFIG_README.md | 2 +
src/config.py | 8 +
src/eos_connect.py | 550 ++++++++++------------------
src/interfaces/base_control.py | 2 +-
src/interfaces/battery_interface.py | 52 ++-
src/interfaces/load_interface.py | 10 +-
src/interfaces/price_interface.py | 385 +++++++++++++++++++
7 files changed, 636 insertions(+), 373 deletions(-)
create mode 100644 src/interfaces/price_interface.py
diff --git a/src/CONFIG_README.md b/src/CONFIG_README.md
index 0eab6663..219074ae 100644
--- a/src/CONFIG_README.md
+++ b/src/CONFIG_README.md
@@ -23,6 +23,8 @@ A default config file will be created with the first start, if there is no confi
- **price.source**: Data source for electricity price. Possible values: `tibber`, `akkudoktor`.
- **price.token**: Token for electricity price.
+- **price.feed_in_price**: Feed-in price for the grid.
+- **price.negative_price_switch**: Switch for handling negative electricity prices (e.g., no payment if negative stock price).
### Battery Configuration
- **battery.source**: Data source for battery SOC. Possible values: openhab, homeassistant, default (static data).
diff --git a/src/config.py b/src/config.py
index 3e335074..b612d947 100644
--- a/src/config.py
+++ b/src/config.py
@@ -59,6 +59,8 @@ def create_default_config(self):
{
"source": "default",
"token": "tibberBearerToken", # token for electricity price
+ "feed_in_price": 0.0, # feed in price for the grid
+ "negative_price_switch": False, # switch for negative price
}
),
"battery": CommentedMap(
@@ -155,6 +157,12 @@ def create_default_config(self):
config["price"].yaml_set_comment_before_after_key(
"token", before="Token for electricity price"
)
+ config["price"].yaml_set_comment_before_after_key(
+ "feed_in_price", before="feed in price for the grid"
+ )
+ config["price"].yaml_set_comment_before_after_key(
+ "negative_price_switch", before="switch for no payment if negative stock price"
+ )
# battery configuration
config.yaml_set_comment_before_after_key("battery", before="battery configuration")
config["battery"].yaml_set_comment_before_after_key(
diff --git a/src/eos_connect.py b/src/eos_connect.py
index 4d3d80c4..881d86d0 100644
--- a/src/eos_connect.py
+++ b/src/eos_connect.py
@@ -8,7 +8,7 @@
import time
import logging
import json
-from threading import Thread
+import threading
import sched
import pytz
import requests
@@ -21,8 +21,10 @@
from interfaces.inverter_fronius import FroniusWR
from interfaces.evcc_interface import EvccInterface
from interfaces.eos_interface import EosInterface
+from interfaces.price_interface import PriceInterface
EOS_TGT_DURATION = 48
+EOS_API_GET_PV_FORECAST = "https://api.akkudoktor.net/forecast"
###################################################################################################
@@ -120,12 +122,6 @@ def charging_state_callback(new_state):
on_charging_state_change=charging_state_callback,
)
-# time.sleep(120)
-
-# evcc_interface.shutdown()
-
-# sys.exit(0)
-
# intialize the load interface
load_interface = LoadInterface(
config_manager.config.get("load", {}).get("source", ""),
@@ -143,209 +139,12 @@ def charging_state_callback(new_state):
config_manager.config.get("battery", {}).get("access_token", ""),
)
-EOS_API_GET_PV_FORECAST = "https://api.akkudoktor.net/forecast"
-AKKUDOKTOR_API_PRICES = "https://api.akkudoktor.net/prices"
-TIBBER_API = "https://api.tibber.com/v1-beta/gql"
-
-
-# getting data
-def get_prices(tgt_duration, start_time=None):
- """
- Retrieve prices based on the target duration and optional start time.
-
- This function fetches prices from different sources based on the configuration.
- It supports fetching prices from 'tibber' and 'default' sources.
-
- Args:
- tgt_duration (int): The target duration for which prices are to be fetched.
- start_time (datetime, optional): The start time from which prices are to be fetched.
- Defaults to None.
-
- Returns:
- list: A list of prices for the specified duration and start time. Returns an empty list
- if the price source is not supported.
- """
- if config_manager.config["price"]["source"] == "tibber":
- return get_prices_from_tibber(tgt_duration, start_time)
- if config_manager.config["price"]["source"] == "default":
- return get_prices_from_akkudoktor(tgt_duration, start_time)
- logger.error("[PRICES] Price source currently not supported.")
- return []
-
-
-def get_prices_from_akkudoktor(tgt_duration, start_time=None):
- """
- Fetches and processes electricity prices for today and tomorrow.
-
- This function retrieves electricity prices for today and tomorrow from an API,
- processes the prices, and returns a list of prices for the specified duration starting
- from the specified start time. If tomorrow's prices are not available, today's prices are
- repeated for tomorrow.
-
- Args:
- tgt_duration (int): The target duration in hours for which the prices are needed.
- start_time (datetime, optional): The start time for fetching prices. Defaults to None.
-
- Returns:
- list: A list of electricity prices for the specified duration starting
- from the specified start time.
- """
- if config_manager.config["price"]["source"] != "default":
- logger.error(
- "[PRICES] Price source %s currently not supported.",
- config_manager.config["price"]["source"],
- )
- return []
- logger.debug("[PRICES] Fetching prices from akkudoktor ...")
- if start_time is None:
- start_time = datetime.now(time_zone).replace(minute=0, second=0, microsecond=0)
- current_hour = start_time.hour
- request_url = (
- AKKUDOKTOR_API_PRICES
- + "?start="
- + start_time.strftime("%Y-%m-%d")
- + "&end="
- + (start_time + timedelta(days=1)).strftime("%Y-%m-%d")
- )
- logger.debug("[PRICES] Requesting prices from akkudoktor: %s", request_url)
- try:
- response = requests.get(request_url, timeout=10)
- response.raise_for_status()
- data = response.json()
- except requests.exceptions.Timeout:
- logger.error(
- "[PRICES] Request timed out while fetching prices from akkudoktor."
- )
- return []
- except requests.exceptions.RequestException as e:
- logger.error(
- "[PRICES] Request failed while fetching prices from akkudoktor: %s", e
- )
- return []
-
- prices = []
- for price in data["values"]:
- prices.append(round(price["marketpriceEurocentPerKWh"] / 100000, 9))
- # logger.debug(
- # "[Main] day 1 - price for %s -> %s", price["marketpriceEurocentPerKWh"],
- # price["start"]
- # )
-
- if start_time is None:
- start_time = datetime.now(time_zone).replace(minute=0, second=0, microsecond=0)
- current_hour = start_time.hour
- extended_prices = prices[current_hour : current_hour + tgt_duration]
-
- if len(extended_prices) < tgt_duration:
- remaining_hours = tgt_duration - len(extended_prices)
- extended_prices.extend(prices[:remaining_hours])
- logger.info("[PRICES] Prices from AKKUDOKTOR fetched successfully.")
- return extended_prices
-
-
-def get_prices_from_tibber(tgt_duration, start_time=None):
- """
- Fetches and processes electricity prices for today and tomorrow.
-
- This function retrieves electricity prices for today and tomorrow from a web service,
- processes the prices, and returns a list of prices for the specified duration starting
- from the specified start time. If tomorrow's prices are not available, today's prices are
- repeated for tomorrow.
-
- Args:
- tgt_duration (int): The target duration in hours for which the prices are needed.
- start_time (datetime, optional): The start time for fetching prices. Defaults to None.
-
- Returns:
- list: A list of electricity prices for the specified duration starting
- from the specified start time.
- """
- logger.debug("[PRICES] Prices fetching from TIBBER started")
- if config_manager.config["price"]["source"] != "tibber":
- logger.error("[PRICES] Price source currently not supported.")
- return []
- headers = {
- "Authorization": config_manager.config["price"]["token"],
- "Content-Type": "application/json",
- }
- query = """
- {
- viewer {
- homes {
- currentSubscription {
- priceInfo {
- today {
- total
- startsAt
- }
- tomorrow {
- total
- startsAt
- }
- }
- }
- }
- }
- }
- """
- try:
- response = requests.post(
- TIBBER_API, headers=headers, json={"query": query}, timeout=10
- )
- response.raise_for_status()
- except requests.exceptions.Timeout:
- logger.error("[PRICES] Request timed out while fetching prices from Tibber.")
- return []
- except requests.exceptions.RequestException as e:
- logger.error("[PRICES] Request failed while fetching prices from Tibber: %s", e)
- return []
-
- response.raise_for_status()
- data = response.json()
- if "errors" in data and data["errors"] is not None:
- logger.error(
- "[PRICES] Error fetching prices - tibber API response: %s",
- data["errors"][0]["message"],
- )
- return []
-
- today_prices = json.dumps(
- data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"]["today"]
- )
- tomorrow_prices = json.dumps(
- data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"][
- "tomorrow"
- ]
- )
-
- today_prices_json = json.loads(today_prices)
- tomorrow_prices_json = json.loads(tomorrow_prices)
- prices = []
-
- for price in today_prices_json:
- prices.append(round(price["total"] / 1000, 9))
- # logger.debug(
- # "[Main] day 1 - price for %s -> %s", price["startsAt"], price["total"]
- # )
- if tomorrow_prices_json:
- for price in tomorrow_prices_json:
- prices.append(round(price["total"] / 1000, 9))
- # logger.debug(
- # "[Main] day 2 - price for %s -> %s", price["startsAt"], price["total"]
- # )
- else:
- prices.extend(prices[:24]) # Repeat today's prices for tomorrow
-
- if start_time is None:
- start_time = datetime.now(time_zone).replace(minute=0, second=0, microsecond=0)
- current_hour = start_time.hour
- extended_prices = prices[current_hour : current_hour + tgt_duration]
-
- if len(extended_prices) < tgt_duration:
- remaining_hours = tgt_duration - len(extended_prices)
- extended_prices.extend(prices[:remaining_hours])
- logger.info("[PRICES] Prices from TIBBER fetched successfully.")
- return extended_prices
+price_interface = PriceInterface(
+ config_manager.config["price"]["source"],
+ config_manager.config["price"]["token"],
+ config_manager.config["price"]["feed_in_price"],
+ config_manager.config["price"]["negative_price_switch"],
+)
def create_forecast_request(pv_config_entry):
@@ -475,62 +274,8 @@ def create_optimize_request():
def get_ems_data():
return {
"pv_prognose_wh": get_summarized_pv_forecast(EOS_TGT_DURATION),
- "strompreis_euro_pro_wh": get_prices(
- EOS_TGT_DURATION,
- datetime.now(time_zone).replace(
- hour=0, minute=0, second=0, microsecond=0
- ),
- ),
- "einspeiseverguetung_euro_pro_wh": [
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- ],
+ "strompreis_euro_pro_wh": price_interface.get_current_prices(),
+ "einspeiseverguetung_euro_pro_wh": price_interface.get_current_feedin_prices(),
"preis_euro_pro_wh_akku": 0,
"gesamtlast": load_interface.get_load_profile(EOS_TGT_DURATION),
}
@@ -547,7 +292,9 @@ def get_pv_akku_data():
"max_charge_power_w": config_manager.config["battery"][
"max_charge_power_w"
],
- "initial_soc_percentage": battery_interface.battery_request_current_soc(),
+ "initial_soc_percentage": round(
+ battery_interface.battery_request_current_soc()
+ ),
"min_soc_percentage": config_manager.config["battery"][
"min_soc_percentage"
],
@@ -564,8 +311,11 @@ def get_wechselrichter_data():
"max_power_wh": config_manager.config["inverter"]["max_pv_charge_rate"],
}
if eos_interface.get_eos_version() == ">=2025-04-09":
- wechselrichter_object = {"device_id": "inverter1", **wechselrichter_object} # at top
- wechselrichter_object["battery_id"] = "battery1" # at the bottom
+ wechselrichter_object = {
+ "device_id": "inverter1",
+ **wechselrichter_object,
+ } # at top
+ wechselrichter_object["battery_id"] = "battery1" # at the bottom
return wechselrichter_object
def get_eauto_data():
@@ -576,17 +326,14 @@ def get_eauto_data():
"max_charge_power_w": 7360,
"initial_soc_percentage": 50,
"min_soc_percentage": 5,
- "max_soc_percentage": 100
+ "max_soc_percentage": 100,
}
if eos_interface.get_eos_version() == ">=2025-04-09":
eauto_object = {"device_id": "ev1", **eauto_object}
return eauto_object
def get_dishwasher_data():
- dishwaser_object = {
- "consumption_wh": 1,
- "duration_h": 1
- }
+ dishwaser_object = {"consumption_wh": 1, "duration_h": 1}
if eos_interface.get_eos_version() == ">=2025-04-09":
dishwaser_object = {"device_id": "dishwasher1", **dishwaser_object}
return dishwaser_object
@@ -610,6 +357,142 @@ def get_dishwasher_data():
return payload
+class OptimizationScheduler:
+ """
+ A scheduler class that manages the periodic execution of an optimization process
+ in a background thread. The class is responsible for starting, stopping, and
+ managing the lifecycle of the optimization service.
+ Attributes:
+ update_interval (int): The interval in seconds between optimization runs.
+ _update_thread (threading.Thread): The background thread running the optimization loop.
+ _stop_event (threading.Event): An event used to signal the thread to stop.
+ Methods:
+ start_update_service():
+ shutdown():
+ _update_state_loop():
+ run_optimization():
+ """
+
+ def __init__(self, update_interval):
+ self.update_interval = update_interval
+ self._update_thread = None
+ self._stop_event = threading.Event()
+ self.start_update_service()
+
+ def start_update_service(self):
+ """
+ Starts the background thread to periodically update the state.
+ """
+ if self._update_thread is None or not self._update_thread.is_alive():
+ self._stop_event.clear()
+ self._update_thread = threading.Thread(
+ target=self._update_state_loop, daemon=True
+ )
+ self._update_thread.start()
+ logger.info("[BATTERY-IF] Update service started.")
+
+ def shutdown(self):
+ """
+ Stops the background thread and shuts down the update service.
+ """
+ if self._update_thread and self._update_thread.is_alive():
+ self._stop_event.set()
+ self._update_thread.join()
+ logger.info("[OPTIMIZATION] Update service stopped.")
+
+ def _update_state_loop(self):
+ """
+ The loop that runs in the background thread to update the state.
+ """
+ while not self._stop_event.is_set():
+ try:
+ self.run_optimization()
+ except (requests.exceptions.RequestException, ValueError, KeyError) as e:
+ logger.error("[BATTERY-IF] Error while updating state: %s", e)
+ # Break the sleep interval into smaller chunks to allow immediate shutdown
+ sleep_interval = self.update_interval
+ while sleep_interval > 0:
+ if self._stop_event.is_set():
+ return # Exit immediately if stop event is set
+ time.sleep(min(1, sleep_interval)) # Sleep in 1-second chunks
+ sleep_interval -= 1
+
+ self.start_update_service()
+
+ def run_optimization(self):
+ """
+ Executes the optimization process by creating an optimization request,
+ sending it to the EOS interface, processing the response, and scheduling
+ the next optimization run.
+ The method performs the following steps:
+ 1. Logs the start of a new optimization run.
+ 2. Creates an optimization request in JSON format and saves it to a file.
+ 3. Sends the optimization request to the EOS interface and retrieves the response.
+ 4. Adds a timestamp to the response and saves it to a file.
+ 5. Extracts control data from the response and, if no error is detected,
+ applies the control settings and updates the control state.
+ 6. Calculates the time for the next optimization run and logs the sleep duration.
+ Raises:
+ Any exceptions raised during file operations, JSON serialization,
+ or EOS interface communication will propagate to the caller.
+ Notes:
+ - The method assumes the presence of global variables or objects such as
+ `logger`, `base_path`, `eos_interface`, `config_manager`, and `time_zone`.
+ - The `config_manager.config` dictionary is expected to contain the
+ necessary configuration values for "eos.timeout" and "refresh_time".
+ """
+ logger.info("[Main] start new run")
+ # update prices
+ price_interface.update_prices(
+ EOS_TGT_DURATION,
+ datetime.now(time_zone).replace(hour=0, minute=0, second=0, microsecond=0),
+ )
+ # create optimize request
+ json_optimize_input = create_optimize_request()
+
+ with open(
+ base_path + "/json/optimize_request.json", "w", encoding="utf-8"
+ ) as file:
+ json.dump(json_optimize_input, file, indent=4)
+
+ optimized_response = eos_interface.eos_set_optimize_request(
+ json_optimize_input, config_manager.config["eos"]["timeout"]
+ )
+ optimized_response["timestamp"] = datetime.now(time_zone).isoformat()
+
+ with open(
+ base_path + "/json/optimize_response.json", "w", encoding="utf-8"
+ ) as file:
+ json.dump(optimized_response, file, indent=4)
+ # +++++++++
+ ac_charge_demand, dc_charge_demand, discharge_allowed, error = (
+ eos_interface.examine_response_to_control_data(optimized_response)
+ )
+ if error is not True:
+ setting_control_data(ac_charge_demand, dc_charge_demand, discharge_allowed)
+ change_control_state()
+ # +++++++++
+
+ loop_now = datetime.now(time_zone)
+ # Reset base to full minutes on the clock
+ next_eval = loop_now.replace(microsecond=0)
+ # Add the update interval to calculate the next evaluation time
+ next_eval += timedelta(seconds=self.update_interval)
+ sleeptime = (next_eval - loop_now).total_seconds()
+ minutes, seconds = divmod(sleeptime, 60)
+ logger.info(
+ "[Main] Next optimization at %s. Sleeping for %d min %.0f seconds\n",
+ next_eval.strftime("%H:%M:%S"),
+ minutes,
+ seconds,
+ )
+
+
+optimization_scheduler = OptimizationScheduler(
+ config_manager.config["refresh_time"] * 60 # convert to seconds
+)
+
+
def setting_control_data(ac_charge_demand_rel, dc_charge_demand_rel, discharge_allowed):
"""
Process the optimized response from EOS and update the load interface.
@@ -707,6 +590,18 @@ def main_page():
return render_template_string(html_file.read())
+@app.route("/style.css", methods=["GET"])
+def style_css():
+ """
+ Serves the CSS file for styling the web application.
+
+ This function reads the content of the 'style.css' file located in the 'web' directory
+ and returns it as a response with the appropriate content type.
+ """
+ with open(base_path + "/web/style.css", "r", encoding="utf-8") as css_file:
+ return Response(css_file.read(), content_type="text/css")
+
+
@app.route("/json/optimize_request.json", methods=["GET"])
def get_optimize_request():
"""
@@ -765,15 +660,18 @@ def serve_current_demands():
current_ac_charge_demand = base_control.get_current_ac_charge_demand()
current_dc_charge_demand = base_control.get_current_dc_charge_demand()
current_discharge_allowed = base_control.get_current_discharge_allowed()
+ current_inverter_mode = base_control.get_current_overall_state(False)
+ current_battery_soc = battery_interface.get_current_soc()
+ base_control.set_current_battery_soc(current_battery_soc)
response_data = {
"current_states": {
"current_ac_charge_demand": current_ac_charge_demand,
"current_dc_charge_demand": current_dc_charge_demand,
"current_discharge_allowed": current_discharge_allowed,
- "inverter_mode": base_control.get_current_overall_state(False),
+ "inverter_mode": current_inverter_mode,
"evcc_charging_state": base_control.get_current_evcc_charging_state(),
},
- "battery_soc": base_control.get_current_battery_soc(),
+ "battery_soc": current_battery_soc,
"timestamp": datetime.now(time_zone).isoformat(),
}
return Response(json.dumps(response_data), content_type="application/json")
@@ -815,7 +713,10 @@ def serve_current_demands():
# ) as file:
# json.dump(optimized_response, file, indent=4)
+ # time.sleep(30)
+
# evcc_interface.shutdown()
+ # battery_interface.shutdown()
# if (
# config_manager.config["inverter"]["type"] == "fronius_gen24"
# and inverter_interface is not None
@@ -831,86 +732,14 @@ def serve_current_demands():
error_log=logger,
)
- def run_optimization_loop():
- """
- Continuously runs the optimization loop until interrupted.
- This function performs the following steps in an infinite loop:
- 1. Logs the start of a new run.
- 2. Creates an optimization request and saves it to a JSON file.
- 3. Sends the optimization request and receives the optimized response.
- 4. Adds a timestamp to the optimized response and saves it to a JSON file.
- 5. Calculates the time to the next evaluation based on a predefined interval.
- 6. Logs the next evaluation time and sleeps until that time.
- The loop can be interrupted with a KeyboardInterrupt, which will log an exit message and
- terminate the program.
- Raises:
- KeyboardInterrupt: If the loop is interrupted by the user.
- """
-
- scheduler = sched.scheduler(time.time, time.sleep)
-
- def run_optimization_event(sc):
- logger.info("[Main] start new run")
- # create optimize request
- json_optimize_input = create_optimize_request()
-
- with open(
- base_path + "/json/optimize_request.json", "w", encoding="utf-8"
- ) as file:
- json.dump(json_optimize_input, file, indent=4)
-
- optimized_response = eos_interface.eos_set_optimize_request(
- json_optimize_input, config_manager.config["eos"]["timeout"]
- )
- optimized_response["timestamp"] = datetime.now(time_zone).isoformat()
-
- with open(
- base_path + "/json/optimize_response.json", "w", encoding="utf-8"
- ) as file:
- json.dump(optimized_response, file, indent=4)
- # +++++++++
- ac_charge_demand, dc_charge_demand, discharge_allowed, error = (
- eos_interface.examine_response_to_control_data(optimized_response)
- )
- if error is not True:
- setting_control_data(
- ac_charge_demand, dc_charge_demand, discharge_allowed
- )
- change_control_state()
- # +++++++++
-
- loop_now = datetime.now(time_zone).astimezone()
- # reset base to full minutes on the clock
- next_eval = loop_now - timedelta(
- minutes=loop_now.minute % config_manager.config["refresh_time"],
- seconds=loop_now.second,
- microseconds=loop_now.microsecond,
- )
- # add time increments to trigger next evaluation
- next_eval += timedelta(
- minutes=config_manager.config["refresh_time"], seconds=0, microseconds=0
- )
- sleeptime = (next_eval - loop_now).total_seconds()
- minutes, seconds = divmod(sleeptime, 60)
- logger.info(
- "[Main] Next optimization at %s. Sleeping for %d min %.0f seconds\n",
- next_eval.astimezone(time_zone).strftime("%H:%M:%S"),
- minutes,
- seconds,
- )
- scheduler.enter(sleeptime, 1, run_optimization_event, (sc,))
-
- scheduler.enter(0, 1, run_optimization_event, (scheduler,))
- scheduler.run()
-
- optimization_thread = Thread(target=run_optimization_loop)
- optimization_thread.start()
-
try:
http_server.serve_forever()
except KeyboardInterrupt:
- logger.info("[Main] Shutting down server")
+ logger.info("[Main] Shutting down EOS connect")
+ optimization_scheduler.shutdown()
http_server.stop()
+ logger.info("[Main] HTTP server stopped")
+
# restore the old config
if (
config_manager.config["inverter"]["type"] == "fronius_gen24"
@@ -918,19 +747,8 @@ def run_optimization_event(sc):
):
inverter_interface.shutdown()
evcc_interface.shutdown()
- optimization_thread.join(timeout=10)
- if optimization_thread.is_alive():
- logger.warning(
- "[Main] Optimization thread did not finish in time, terminating."
- )
- # Terminate the thread (not recommended, but shown here for completeness)
- # Note: Python does not provide a direct way to kill a thread. This is a workaround.
- import ctypes
-
- if optimization_thread.ident is not None:
- ctypes.pythonapi.PyThreadState_SetAsyncExc(
- ctypes.c_long(optimization_thread.ident),
- ctypes.py_object(SystemExit),
- )
+ battery_interface.shutdown()
logger.info("[Main] Server stopped")
+ finally:
+ logger.info("[Main] Cleanup complete. Exiting.")
sys.exit(0)
diff --git a/src/interfaces/base_control.py b/src/interfaces/base_control.py
index 78529094..a6615511 100644
--- a/src/interfaces/base_control.py
+++ b/src/interfaces/base_control.py
@@ -200,4 +200,4 @@ def set_current_battery_soc(self, value):
Sets the current battery state of charge (SOC).
"""
self.current_battery_soc = value
- logger.debug("[BASE_CTRL] set current battery SOC to %s", value)
+ # logger.debug("[BASE_CTRL] set current battery SOC to %s", value)
diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py
index 4101b0ac..989bc2c0 100644
--- a/src/interfaces/battery_interface.py
+++ b/src/interfaces/battery_interface.py
@@ -25,6 +25,8 @@
```
"""
import logging
+import threading
+import time
import requests
logger = logging.getLogger("__main__")
@@ -55,6 +57,10 @@ def __init__(self, src, url, soc_sensor, access_token):
self.soc_sensor = soc_sensor
self.access_token = access_token
self.current_soc = 0
+ self.update_interval = 30
+ self._update_thread = None
+ self._stop_event = threading.Event()
+ self.start_update_service()
def fetch_soc_data_from_openhab(self):
"""
@@ -107,7 +113,7 @@ def fetch_soc_data_from_homeassistant(self):
requests.exceptions.Timeout: If the request to the Home Assistant API times out.
requests.exceptions.RequestException: If there is an error during the request.
"""
- logger.debug("[BATTERY-IF] getting SOC from homeassistant ...")
+ # logger.debug("[BATTERY-IF] getting SOC from homeassistant ...")
homeassistant_url = f"{self.url}/api/states/{self.soc_sensor}"
# Headers for the API request
headers = {
@@ -122,8 +128,8 @@ def fetch_soc_data_from_homeassistant(self):
# print(f'Entity data: {entity_data}')
soc = float(entity_data["state"])
# print(f'State: {state}')
- logger.info("[BATTERY-IF] successfully fetched SOC = %s %%", soc)
- return round(soc)
+ logger.debug("[BATTERY-IF] successfully fetched SOC = %s %%", soc)
+ return round(soc,1)
except requests.exceptions.Timeout:
logger.error(
(
@@ -166,3 +172,43 @@ def get_current_soc(self):
Returns the current state of charge (SOC) of the battery.
"""
return self.current_soc
+
+ def start_update_service(self):
+ """
+ Starts the background thread to periodically update the state.
+ """
+ if self._update_thread is None or not self._update_thread.is_alive():
+ self._stop_event.clear()
+ self._update_thread = threading.Thread(
+ target=self._update_state_loop, daemon=True
+ )
+ self._update_thread.start()
+ logger.info("[BATTERY-IF] Update service started.")
+
+ def shutdown(self):
+ """
+ Stops the background thread and shuts down the update service.
+ """
+ if self._update_thread and self._update_thread.is_alive():
+ self._stop_event.set()
+ self._update_thread.join()
+ logger.info("[BATTERY-IF] Update service stopped.")
+
+ def _update_state_loop(self):
+ """
+ The loop that runs in the background thread to update the state.
+ """
+ while not self._stop_event.is_set():
+ try:
+ self.battery_request_current_soc()
+ except (requests.exceptions.RequestException, ValueError, KeyError) as e:
+ logger.error("[BATTERY-IF] Error while updating state: %s", e)
+ # Break the sleep interval into smaller chunks to allow immediate shutdown
+ sleep_interval = self.update_interval
+ while sleep_interval > 0:
+ if self._stop_event.is_set():
+ return # Exit immediately if stop event is set
+ time.sleep(min(1, sleep_interval)) # Sleep in 1-second chunks
+ sleep_interval -= 1
+
+ self.start_update_service()
diff --git a/src/interfaces/load_interface.py b/src/interfaces/load_interface.py
index b085dc16..80b7306a 100644
--- a/src/interfaces/load_interface.py
+++ b/src/interfaces/load_interface.py
@@ -400,7 +400,8 @@ def get_load_profile_for_day(self, start_time, end_time):
if not load_profile:
logger.error(
"[LOAD-IF] No load profile data available for the specified day - % s to % s",
- start_time, end_time
+ start_time,
+ end_time,
)
return load_profile
@@ -459,12 +460,15 @@ def create_load_profile_homeassistant_weekdays(self):
# combine to a list with 48 values
load_profile = []
for i, value in enumerate(load_profile_one_week_before):
- if load_profile_two_week_before:
+ if load_profile_two_week_before and len(load_profile_two_week_before) >= 24:
load_profile.append((value + load_profile_two_week_before[i]) / 2)
else:
load_profile.append(value)
for i, value in enumerate(load_profile_tomorrow_one_week_before):
- if load_profile_tomorrow_two_week_before:
+ if (
+ load_profile_tomorrow_two_week_before
+ and len(load_profile_tomorrow_two_week_before) >= 24
+ ):
load_profile.append(
(value + load_profile_tomorrow_two_week_before[i]) / 2
)
diff --git a/src/interfaces/price_interface.py b/src/interfaces/price_interface.py
new file mode 100644
index 00000000..2b51d2af
--- /dev/null
+++ b/src/interfaces/price_interface.py
@@ -0,0 +1,385 @@
+'''
+This module provides the `PriceInterface` class, which is designed to handle electricity price
+data retrieval, processing, and management from various sources. It includes methods for fetching
+current prices, generating feed-in prices, and updating price data for a specified duration and
+start time. The module supports integration with the Akkudoktor API and Tibber API for retrieving
+electricity prices.
+Classes:
+ PriceInterface: A class for managing electricity price data, including fetching, processing,
+ and generating feed-in prices.
+Constants:
+ AKKUDOKTOR_API_PRICES (str): The URL for the Akkudoktor API to fetch electricity prices.
+ TIBBER_API (str): The URL for the Tibber API to fetch electricity prices.
+Dependencies:
+ - datetime: For handling date and time operations.
+ - json: For parsing JSON responses from APIs.
+ - logging: For logging messages and errors.
+ - requests: For making HTTP requests to APIs.
+Usage:
+ Create an instance of the `PriceInterface` class with the desired configuration, and use its
+ methods to fetch and process electricity price data.
+Example:
+ price_interface = PriceInterface(
+ src="tibber",
+ access_token="your_access_token",
+ feed_in_tariff_price=5.0,
+ negative_price_switch=True,
+ timezone="Europe/Berlin"
+ price_interface.update_prices(tgt_duration=24, start_time=datetime.now())
+ current_prices = price_interface.get_current_prices()
+ current_feedin_prices = price_interface.get_current_feedin_prices()
+'''
+from datetime import datetime, timedelta
+import json
+import logging
+import requests
+
+logger = logging.getLogger("__main__")
+logger.info("[PRICE-IF] loading module ")
+
+AKKUDOKTOR_API_PRICES = "https://api.akkudoktor.net/prices"
+TIBBER_API = "https://api.tibber.com/v1-beta/gql"
+
+
+class PriceInterface:
+ '''
+ PriceInterface is a class designed to handle electricity price data retrieval, processing,
+ and management from various sources. It provides methods to fetch current prices,
+ generate feed-in prices, and update price data for a specified duration and start time.
+ Attributes:
+ src (str): The source of the price data (e.g., 'tibber', 'default').
+ access_token (str): The access token for authenticating with the price source.
+ feed_in_tariff_price (float): The feed-in tariff price in cents per kWh.
+ negative_price_switch (bool): A flag to determine whether to set feed-in prices to 0
+ for negative prices.
+ timezone (str): The timezone used for date and time operations.
+ current_prices (list): A list of current prices fetched from the price source.
+ current_prices_direct (list): A list of current prices without tax.
+ current_feedin (list): A list of current feed-in prices.
+ Methods:
+ update_prices(tgt_duration, start_time):
+ Updates the current prices and feed-in prices based on the target duration and
+ start time.
+ get_current_prices():
+ Returns the current prices fetched from the price source.
+ get_current_feedin_prices():
+ Returns the current feed-in prices fetched from the price source.
+ __create_feedin_prices():
+ Creates feed-in prices based on the current prices and the configured feed-in
+ tariff price.
+ __retrieve_prices(tgt_duration, start_time=None):
+ Retrieves prices based on the target duration and optional start time from the
+ configured source.
+ __retrieve_prices_from_akkudoktor(tgt_duration, start_time=None):
+ Fetches and processes electricity prices for today and tomorrow from the
+ Akkudoktor API.
+ __retrieve_prices_from_tibber(tgt_duration, start_time=None):
+ Fetches and processes electricity prices for today and tomorrow from the Tibber API.
+ '''
+ def __init__(
+ self,
+ src,
+ access_token,
+ feed_in_tariff_price=0.0,
+ negative_price_switch=False,
+ timezone="UTC",
+ ):
+ self.src = src
+ self.access_token = access_token
+ self.feed_in_tariff_price = feed_in_tariff_price
+ self.negative_price_switch = negative_price_switch
+ self.time_zone = timezone
+ self.current_prices = []
+ self.current_prices_direct = [] # without tax
+ self.current_feedin = []
+
+ def update_prices(self, tgt_duration, start_time):
+ """
+ Updates the current prices and feed-in prices based on the target duration
+ and start time provided.
+
+ Args:
+ tgt_duration (int): The target duration for which prices need to be retrieved.
+ start_time (datetime): The starting time for retrieving prices.
+
+ Updates:
+ self.current_prices: Updates with the retrieved prices for the given duration
+ and start time.
+ self.current_feedin: Updates with the generated feed-in prices.
+
+ Logs:
+ Logs a debug message indicating that prices have been updated.
+ """
+ self.current_prices = self.__retrieve_prices(tgt_duration, start_time)
+ self.current_feedin = self.__create_feedin_prices()
+ logger.debug("[PRICE-IF] Prices updated")
+
+ def get_current_prices(self):
+ """
+ Returns the current prices.
+
+ This function returns the current prices fetched from the price source.
+ If the source is not supported, it returns an empty list.
+
+ Returns:
+ list: A list of current prices.
+ """
+ return self.current_prices
+
+ def get_current_feedin_prices(self):
+ """
+ Returns the current feed-in prices.
+
+ This function returns the current feed-in prices fetched from the price source.
+ If the source is not supported, it returns an empty list.
+
+ Returns:
+ list: A list of current feed-in prices.
+ """
+ return self.current_feedin
+
+ def __create_feedin_prices(self):
+ """
+ Creates feed-in prices based on the current prices.
+
+ This function generates feed-in prices based on the current prices and the
+ configured feed-in tariff price. If the negative price switch is enabled,
+ feed-in prices are set to 0 for negative prices. Otherwise, the feed-in tariff
+ price is used for all prices.
+
+ Returns:
+ list: A list of feed-in prices.
+ """
+ if self.negative_price_switch:
+ self.current_feedin = [
+ 0 if price < 0 else round(self.feed_in_tariff_price / 1000, 9)
+ for price in self.current_prices_direct
+ ]
+ else:
+ self.current_feedin = [
+ round(self.feed_in_tariff_price / 1000, 9)
+ for price in self.current_prices_direct
+ ]
+ return self.current_feedin
+
+ def __retrieve_prices(self, tgt_duration, start_time=None):
+ """
+ Retrieve prices based on the target duration and optional start time.
+
+ This function fetches prices from different sources based on the configuration.
+ It supports fetching prices from 'tibber' and 'default' sources.
+
+ Args:
+ tgt_duration (int): The target duration for which prices are to be fetched.
+ start_time (datetime, optional): The start time from which prices are to be fetched.
+ Defaults to None.
+
+ Returns:
+ list: A list of prices for the specified duration and start time. Returns an empty list
+ if the price source is not supported.
+ """
+ if self.src == "tibber":
+ return self.__retrieve_prices_from_tibber(tgt_duration, start_time)
+ if self.src == "default":
+ return self.__retrieve_prices_from_akkudoktor(tgt_duration, start_time)
+ logger.error("[PRICE-IF] Price source currently not supported.")
+ return []
+
+ def __retrieve_prices_from_akkudoktor(self, tgt_duration, start_time=None):
+ """
+ Fetches and processes electricity prices for today and tomorrow.
+
+ This function retrieves electricity prices for today and tomorrow from an API,
+ processes the prices, and returns a list of prices for the specified duration starting
+ from the specified start time. If tomorrow's prices are not available, today's prices are
+ repeated for tomorrow.
+
+ Args:
+ tgt_duration (int): The target duration in hours for which the prices are needed.
+ start_time (datetime, optional): The start time for fetching prices. Defaults to None.
+
+ Returns:
+ list: A list of electricity prices for the specified duration starting
+ from the specified start time.
+ """
+ if self.src != "default":
+ logger.error(
+ "[PRICE-IF] Price source %s currently not supported.",
+ self.src,
+ )
+ return []
+ logger.debug("[PRICE-IF] Fetching prices from akkudoktor ...")
+ if start_time is None:
+ start_time = datetime.now(self.time_zone).replace(
+ minute=0, second=0, microsecond=0
+ )
+ current_hour = start_time.hour
+ request_url = (
+ AKKUDOKTOR_API_PRICES
+ + "?start="
+ + start_time.strftime("%Y-%m-%d")
+ + "&end="
+ + (start_time + timedelta(days=1)).strftime("%Y-%m-%d")
+ )
+ logger.debug("[PRICE-IF] Requesting prices from akkudoktor: %s", request_url)
+ try:
+ response = requests.get(request_url, timeout=10)
+ response.raise_for_status()
+ data = response.json()
+ except requests.exceptions.Timeout:
+ logger.error(
+ "[PRICE-IF] Request timed out while fetching prices from akkudoktor."
+ )
+ return []
+ except requests.exceptions.RequestException as e:
+ logger.error(
+ "[PRICE-IF] Request failed while fetching prices from akkudoktor: %s", e
+ )
+ return []
+
+ prices = []
+ for price in data["values"]:
+ prices.append(round(price["marketpriceEurocentPerKWh"] / 100000, 9))
+ # logger.debug(
+ # "[Main] day 1 - price for %s -> %s", price["marketpriceEurocentPerKWh"],
+ # price["start"]
+ # )
+
+ if start_time is None:
+ start_time = datetime.now(self.time_zone).replace(
+ minute=0, second=0, microsecond=0
+ )
+ current_hour = start_time.hour
+ extended_prices = prices[current_hour : current_hour + tgt_duration]
+
+ if len(extended_prices) < tgt_duration:
+ remaining_hours = tgt_duration - len(extended_prices)
+ extended_prices.extend(prices[:remaining_hours])
+ logger.info("[PRICE-IF] Prices from AKKUDOKTOR fetched successfully.")
+ self.current_prices_direct = extended_prices.copy()
+ return extended_prices
+
+ def __retrieve_prices_from_tibber(self, tgt_duration, start_time=None):
+ """
+ Fetches and processes electricity prices for today and tomorrow.
+
+ This function retrieves electricity prices for today and tomorrow from a web service,
+ processes the prices, and returns a list of prices for the specified duration starting
+ from the specified start time. If tomorrow's prices are not available, today's prices are
+ repeated for tomorrow.
+
+ Args:
+ tgt_duration (int): The target duration in hours for which the prices are needed.
+ start_time (datetime, optional): The start time for fetching prices. Defaults to None.
+
+ Returns:
+ list: A list of electricity prices for the specified duration starting
+ from the specified start time.
+ """
+ logger.debug("[PRICE-IF] Prices fetching from TIBBER started")
+ if self.src != "tibber":
+ logger.error("[PRICE-IF] Price source currently not supported.")
+ return []
+ headers = {
+ "Authorization": self.access_token,
+ "Content-Type": "application/json",
+ }
+ query = """
+ {
+ viewer {
+ homes {
+ currentSubscription {
+ priceInfo {
+ today {
+ total
+ energy
+ startsAt
+ }
+ tomorrow {
+ total
+ energy
+ startsAt
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ try:
+ response = requests.post(
+ TIBBER_API, headers=headers, json={"query": query}, timeout=10
+ )
+ response.raise_for_status()
+ except requests.exceptions.Timeout:
+ logger.error(
+ "[PRICE-IF] Request timed out while fetching prices from Tibber."
+ )
+ return []
+ except requests.exceptions.RequestException as e:
+ logger.error(
+ "[PRICE-IF] Request failed while fetching prices from Tibber: %s", e
+ )
+ return []
+
+ response.raise_for_status()
+ data = response.json()
+ if "errors" in data and data["errors"] is not None:
+ logger.error(
+ "[PRICE-IF] Error fetching prices - tibber API response: %s",
+ data["errors"][0]["message"],
+ )
+ return []
+
+ today_prices = json.dumps(
+ data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"][
+ "today"
+ ]
+ )
+ tomorrow_prices = json.dumps(
+ data["data"]["viewer"]["homes"][0]["currentSubscription"]["priceInfo"][
+ "tomorrow"
+ ]
+ )
+
+ today_prices_json = json.loads(today_prices)
+ tomorrow_prices_json = json.loads(tomorrow_prices)
+ prices = []
+ prices_direct = []
+
+ for price in today_prices_json:
+ prices.append(round(price["total"] / 1000, 9))
+ prices_direct.append(round(price["energy"] / 1000, 9))
+ # logger.debug(
+ # "[Main] day 1 - price for %s -> %s", price["startsAt"], price["total"]
+ # )
+ if tomorrow_prices_json:
+ for price in tomorrow_prices_json:
+ prices.append(round(price["total"] / 1000, 9))
+ prices_direct.append(round(price["energy"] / 1000, 9))
+ # logger.debug(
+ # "[Main] day 2 - price for %s -> %s", price["startsAt"], price["total"]
+ # )
+ else:
+ prices.extend(prices[:24]) # Repeat today's prices for tomorrow
+ prices_direct.extend(
+ prices_direct[:24]
+ ) # Repeat today's prices for tomorrow
+
+ if start_time is None:
+ start_time = datetime.now(self.time_zone).replace(
+ minute=0, second=0, microsecond=0
+ )
+ current_hour = start_time.hour
+ extended_prices = prices[current_hour : current_hour + tgt_duration]
+ extended_prices_direct = prices_direct[
+ current_hour : current_hour + tgt_duration
+ ]
+
+ if len(extended_prices) < tgt_duration:
+ remaining_hours = tgt_duration - len(extended_prices)
+ extended_prices.extend(prices[:remaining_hours])
+ extended_prices_direct.extend(prices_direct[:remaining_hours])
+ self.current_prices_direct = extended_prices_direct.copy()
+ logger.info("[PRICE-IF] Prices from TIBBER fetched successfully.")
+ return extended_prices
From 6efc236698ffaaa903c774e59cccf8867d66315d Mon Sep 17 00:00:00 2001
From: ohAnd <15704728+ohAnd@users.noreply.github.com>
Date: Sat, 12 Apr 2025 22:40:05 +0200
Subject: [PATCH 2/3] remerge with main
---
src/interfaces/evcc_interface.py | 8 +-
src/web/index.html | 183 ++++---------------------------
src/web/style.css | 151 +++++++++++++++++++++++++
3 files changed, 178 insertions(+), 164 deletions(-)
create mode 100644 src/web/style.css
diff --git a/src/interfaces/evcc_interface.py b/src/interfaces/evcc_interface.py
index c982f42a..e81e4cef 100644
--- a/src/interfaces/evcc_interface.py
+++ b/src/interfaces/evcc_interface.py
@@ -101,7 +101,13 @@ def _update_charging_state_loop(self):
self.request_charging_state()
except (requests.exceptions.RequestException, ValueError, KeyError) as e:
logger.error("[EVCC] Error while updating charging state: %s", e)
- time.sleep(self.update_interval)
+ # Break the sleep interval into smaller chunks to allow immediate shutdown
+ sleep_interval = self.update_interval
+ while sleep_interval > 0:
+ if self._stop_event.is_set():
+ return # Exit immediately if stop event is set
+ time.sleep(min(1, sleep_interval)) # Sleep in 1-second chunks
+ sleep_interval -= 1
self.start_update_service()
diff --git a/src/web/index.html b/src/web/index.html
index e1a0306c..907e97e6 100644
--- a/src/web/index.html
+++ b/src/web/index.html
@@ -6,161 +6,9 @@
EOS connect board
-
+
@@ -581,6 +429,17 @@
}
+ async function showCurrentData() {
+ console.log("------- showCurrentControls -------");
+ const data_controls = await fetch_EOS_Connect_Data("current_controls.json");
+ document.getElementById('control_ac_charge').innerText = data_controls["current_states"]["current_ac_charge_demand"].toFixed(0) + " Wh";
+ document.getElementById('control_dc_charge').innerText = data_controls["current_states"]["current_dc_charge_demand"].toFixed(0) + " Wh";
+ document.getElementById('control_discharge_allowed').innerText = (data_controls["current_states"]["current_discharge_allowed"] === true ? "Yes" : "No");
+ document.getElementById('control_overall').innerText = data_controls["current_states"]["inverter_mode"];
+
+ document.getElementById('battery_soc').innerText = data_controls["battery_soc"] + " %";
+ }
+
function showBatteryCharge(data_request, data_response) {
console.log("------- showBatteryCharge -------");
var discharge_allowed = data_response["discharge_allowed"];
@@ -672,7 +531,12 @@
if(data_response["error"] === "Request timed out"){
document.getElementById('waiting_text').innerText = "No data available...";
document.getElementById('waiting_error_text').innerText = "Error: " + data_response["error"];
- } else {
+ }
+ else if(data_response["error"].includes("422 Client Error: Unprocessable Entity")){
+ document.getElementById('waiting_text').innerText = "No data available...";
+ document.getElementById('waiting_error_text').innerText = "Error: " + data_response["error"];
+ }
+ else {
document.getElementById('waiting_text').innerText = "No data available...";
document.getElementById('waiting_error_text').innerText = "no further error information available";
}
@@ -703,15 +567,8 @@
//document.getElementById('timestamp_battery').innerHTML = data["energyBatteryChargeDischargeJSON"].timestamp;
showBatteryCharge(data_request,data_response);
- //await displayCurrentValues(data);
-
- const data_controls = await fetch_EOS_Connect_Data("current_controls.json");
- document.getElementById('control_ac_charge').innerText = data_controls["current_states"]["current_ac_charge_demand"].toFixed(0) + " Wh";
- document.getElementById('control_dc_charge').innerText = data_controls["current_states"]["current_dc_charge_demand"].toFixed(0) + " Wh";
- document.getElementById('control_discharge_allowed').innerText = (data_controls["current_states"]["current_discharge_allowed"] === true ? "Yes" : "No");
- document.getElementById('control_overall').innerText = data_controls["current_states"]["inverter_mode"];
-
- document.getElementById('battery_soc').innerText = data_controls["battery_soc"] + " %";
+
+ await showCurrentData();
var currentHour = new Date().getHours();
diff --git a/src/web/style.css b/src/web/style.css
new file mode 100644
index 00000000..a0d38d93
--- /dev/null
+++ b/src/web/style.css
@@ -0,0 +1,151 @@
+body {
+ margin: 0;
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ background-color: rgb(54, 54, 54);
+ color: lightgray;
+}
+
+.container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.top-boxes,
+.bottom-boxes {
+ display: flex;
+}
+
+.top-boxes {
+ height: 20%;
+}
+
+.top-box {
+ flex: 1;
+ margin: 10px;
+ padding: 10px;
+ background-color: rgb(78, 78, 78);
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+ border-radius: 10px;
+ display: flex;
+ flex-direction: column;
+ font-size: 0.93em;
+}
+
+.bottom-boxes {
+ height: 80%;
+}
+
+.left-box {
+ width: 75%;
+ margin: 10px;
+ padding: 10px;
+ border-radius: 10px;
+ background-color: rgb(78, 78, 78);
+}
+
+.right-box {
+ width: 24%;
+ margin: 10px;
+ padding: 10px;
+ border-radius: 10px;
+ background-color: rgb(78, 78, 78);
+ display: flex;
+ flex-direction: column;
+}
+
+.header {
+ background-color: rgb(114, 114, 114);
+ ;
+ color: white;
+ padding: 10px;
+ text-align: center;
+ border-radius: 10px;
+ position: relative;
+}
+
+.header_notification {
+ background-color: rgb(58, 58, 58);
+ color: white;
+ padding: 4px;
+ text-align: center;
+ border-radius: 5px;
+ font-size: 0.65em;
+ position: absolute;
+ top: 10px;
+ right: 10px;
+}
+
+.content {
+ padding: 10px;
+ flex: 1;
+ overflow: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ padding: 0 5px 0 5px;
+ text-align: left;
+}
+th {
+ text-align:center;
+}
+
+/* Media Queries for Smartphones */
+@media (max-width: 768px) {
+ .top-boxes {
+ flex-direction: column;
+ height: auto;
+ }
+
+ .top-box {
+ min-height: 150px;
+ font-size: 0.73em;
+ }
+
+ .bottom-boxes {
+ flex-direction: column;
+ height: auto;
+ }
+
+ .left-box,
+ .right-box {
+ width: auto;
+ height: auto;
+ }
+}
+
+.table {
+ display: table;
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table-header, .table-body {
+ display: table-row-group;
+}
+
+.table-row {
+ display: table-row;
+}
+
+.table-cell {
+ display: table-cell;
+ padding: 1px 5px;
+ text-align: left;
+ margin: 2px;
+ border-radius: 20px;
+}
+
+.table-cell.rounded {
+ border-radius: 10px;
+ overflow: hidden;
+}
\ No newline at end of file
From bea475165251b2d089795cc9224f4378c85aadf2 Mon Sep 17 00:00:00 2001
From: ohAnd <15704728+ohAnd@users.noreply.github.com>
Date: Sun, 13 Apr 2025 10:56:50 +0200
Subject: [PATCH 3/3] feat: Add dynamic maximum charge power calculation
(charging curve) and update UI to display current values
---
README.md | 1 +
src/eos_connect.py | 68 +++++++----------------------
src/interfaces/battery_interface.py | 63 +++++++++++++++++++++-----
src/web/index.html | 37 ++++++++--------
4 files changed, 88 insertions(+), 81 deletions(-)
diff --git a/README.md b/README.md
index 64adfc34..12f659e5 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ EOS Connect is a tool designed to optimize energy usage by interacting with the
* Manages configurations via a user-friendly config.yaml file.
* Displays results dynamically on a webpage.
* Controlling FRONIUS inverters and battery charging systems interactively.
+* Battery charging is dynamicaly limited depending on the SOC (charging curve)
## Current Status
diff --git a/src/eos_connect.py b/src/eos_connect.py
index 881d86d0..06de8cbe 100644
--- a/src/eos_connect.py
+++ b/src/eos_connect.py
@@ -9,7 +9,6 @@
import logging
import json
import threading
-import sched
import pytz
import requests
from flask import Flask, Response, render_template_string
@@ -137,6 +136,7 @@ def charging_state_callback(new_state):
config_manager.config.get("battery", {}).get("url", ""),
config_manager.config.get("battery", {}).get("soc_sensor", ""),
config_manager.config.get("battery", {}).get("access_token", ""),
+ config_manager.config.get("battery", {}).get("max_charge_power_w", ""),
)
price_interface = PriceInterface(
@@ -146,7 +146,6 @@ def charging_state_callback(new_state):
config_manager.config["price"]["negative_price_switch"],
)
-
def create_forecast_request(pv_config_entry):
"""
Creates a forecast request URL for the EOS server.
@@ -209,7 +208,11 @@ def get_pv_forecast(tgt_value="power", pv_config_entry=None, tgt_duration=24):
for forecast in forecast_entry:
entry_time = datetime.fromisoformat(forecast["datetime"]).astimezone()
if current_time <= entry_time < end_time:
- forecast_values.append(forecast.get(tgt_value, 0))
+ value = forecast.get(tgt_value, 0)
+ # if power is negative, set it to 0 (fixing wrong values form api)
+ if tgt_value == "power" and value < 0:
+ value = 0
+ forecast_values.append(value)
request_type = "PV forecast"
pv_config_name = "for " + pv_config_entry["name"]
if tgt_value == "temperature":
@@ -538,13 +541,17 @@ def change_control_state():
logger.debug("[Main] Overall state changed recently")
# MODE_CHARGE_FROM_GRID
if base_control.get_current_overall_state() == 0:
+ # get the current ac charge demand and set it to the inverter according
+ # to the max dynamic charge power of the battery based on SOC
+ tgt_charge_power = min(
+ base_control.get_current_ac_charge_demand(),
+ round(battery_interface.get_max_charge_power_dyn())
+ )
if inverter_en:
- inverter_interface.set_mode_force_charge(
- base_control.get_current_ac_charge_demand()
- )
+ inverter_interface.set_mode_force_charge(tgt_charge_power)
logger.info(
"[Main] Inverter mode set to charge from grid with %s W (_____|||||_____)",
- base_control.get_current_ac_charge_demand(),
+ tgt_charge_power,
)
# MODE_AVOID_DISCHARGE
elif base_control.get_current_overall_state() == 1:
@@ -672,58 +679,13 @@ def serve_current_demands():
"evcc_charging_state": base_control.get_current_evcc_charging_state(),
},
"battery_soc": current_battery_soc,
+ "battery_max_charge_power_dyn": battery_interface.get_max_charge_power_dyn(),
"timestamp": datetime.now(time_zone).isoformat(),
}
return Response(json.dumps(response_data), content_type="application/json")
if __name__ == "__main__":
- # initial config
- # set_config_value("latitude", 48.812)
- # set_config_value("longitude", 8.907)
-
- # set_config_value("measurement_load0_name", "Household")
- # set_config_value("loadakkudoktor_year_energy", 4600)
-
- # # set_config_value("pvforecast_provider", "PVForecastAkkudoktor")
- # set_config_value("pvforecast_provider", "PVForecast")
- # set_config_value("pvforecast0_surface_tilt", 31)
- # set_config_value("pvforecast0_surface_azimuth", 13)
- # set_config_value("pvforecast0_peakpower", 860.0)
- # set_config_value("pvforecast0_inverter_paco", 800)
- # # set_config_value("pvforecast0_userhorizon", [0,0])
-
- # # persist and update config
- # eos_save_config_to_config_file()
-
- # json_optimize_input = create_optimize_request()
-
- # with open(
- # base_path + "/json/optimize_request.json", "w", encoding="utf-8"
- # ) as file:
- # json.dump(json_optimize_input, file, indent=4)
-
- # optimized_response = eos_interface.eos_set_optimize_request(
- # json_optimize_input, config_manager.config["eos"]["timeout"]
- # )
- # optimized_response["timestamp"] = datetime.now(time_zone).isoformat()
-
- # with open(
- # base_path + "/json/optimize_response.json", "w", encoding="utf-8"
- # ) as file:
- # json.dump(optimized_response, file, indent=4)
-
- # time.sleep(30)
-
- # evcc_interface.shutdown()
- # battery_interface.shutdown()
- # if (
- # config_manager.config["inverter"]["type"] == "fronius_gen24"
- # and inverter_interface is not None
- # ):
- # inverter_interface.shutdown()
-
- # sys.exit()
http_server = WSGIServer(
("0.0.0.0", config_manager.config["eos_connect_web_port"]),
diff --git a/src/interfaces/battery_interface.py b/src/interfaces/battery_interface.py
index 989bc2c0..b2f89c96 100644
--- a/src/interfaces/battery_interface.py
+++ b/src/interfaces/battery_interface.py
@@ -1,8 +1,19 @@
"""
-This module provides the `BatteryInterface` class, which serves as an interface for fetching
+- BatteryInterface: A class to interact with SOC data sources, retrieve battery SOC, and calculate
+ dynamic maximum charge power based on SOC.
+- threading: For managing background update services.
+- time: For managing sleep intervals in the update loop.
+sensor identifier, access token (if required), and maximum fixed charge power. Use the
+`battery_request_current_soc` method to fetch the current SOC value or `get_max_charge_power_dyn`
+to calculate the dynamic maximum charge power.
+ access_token=None,
+ max_charge_power_w=3000
+max_charge_power = battery_interface.get_max_charge_power_dyn()
+print(f"Max Charge Power: {max_charge_power}W")
+This module provides the `BatteryInterface` class, which serves as an interface for fetching
the State of Charge (SOC) data of a battery from various sources such as OpenHAB and Home Assistant.
-The `BatteryInterface` class allows users to configure the source of SOC data and retrieve the
-current SOC value through its methods. It supports fetching SOC data from OpenHAB using its REST API
+The `BatteryInterface` class allows users to configure the source of SOC data and retrieve the
+current SOC value through its methods. It supports fetching SOC data from OpenHAB using its REST API
and from Home Assistant using its API with authentication.
Classes:
- BatteryInterface: A class to interact with SOC data sources and retrieve battery SOC.
@@ -24,6 +35,7 @@
print(f"Current SOC: {current_soc}%")
```
"""
+
import logging
import threading
import time
@@ -51,11 +63,12 @@ class BatteryInterface:
Fetches the current SOC of the battery based on the configured source.
"""
- def __init__(self, src, url, soc_sensor, access_token):
+ def __init__(self, src, url, soc_sensor, access_token, max_charge_power_w):
self.src = src
self.url = url
self.soc_sensor = soc_sensor
self.access_token = access_token
+ self.max_charge_power_fix = max_charge_power_w
self.current_soc = 0
self.update_interval = 30
self._update_thread = None
@@ -87,7 +100,8 @@ def fetch_soc_data_from_openhab(self):
except requests.exceptions.Timeout:
logger.error(
"[BATTERY-IF] OPENHAB - Request timed out while fetching battery SOC. "
- "Using default SOC = %s%%.", soc
+ "Using default SOC = %s%%.",
+ soc,
)
return soc # Default SOC value in case of timeout
except requests.exceptions.RequestException as e:
@@ -96,7 +110,8 @@ def fetch_soc_data_from_openhab(self):
"[BATTERY-IF] OPENHAB - Request failed while fetching battery SOC: %s. "
"Using default SOC = %s%%."
),
- e,soc
+ e,
+ soc,
)
return soc # Default SOC value in case of request failure
@@ -129,12 +144,13 @@ def fetch_soc_data_from_homeassistant(self):
soc = float(entity_data["state"])
# print(f'State: {state}')
logger.debug("[BATTERY-IF] successfully fetched SOC = %s %%", soc)
- return round(soc,1)
+ return round(soc, 1)
except requests.exceptions.Timeout:
logger.error(
(
"[BATTERY-IF] HOMEASSISTANT - Request timed out while fetching battery SOC. "
- "Using default SOC = %s%%.", soc
+ "Using default SOC = %s%%.",
+ soc,
)
)
return soc # Default SOC value in case of timeout
@@ -144,7 +160,8 @@ def fetch_soc_data_from_homeassistant(self):
"[BATTERY-IF] HOMEASSISTANT - Request failed while fetching battery SOC: %s. "
"Using default SOC = %s %%."
),
- e,soc
+ e,
+ soc,
)
return soc # Default SOC value in case of request failure
@@ -173,6 +190,32 @@ def get_current_soc(self):
"""
return self.current_soc
+ def get_max_charge_power_dyn(self, soc=None):
+ """
+ Calculates the maximum charge power of the battery depending on the SOC.
+
+ - If SOC < 60%, the fixed maximum charge power is used.
+ - If SOC >= 60% and < 95%, the maximum charge power decreases linearly.
+ - If SOC >= 95%, the maximum charge power is fixed at 500W.
+
+ Args:
+ soc (float, optional): The state of charge to use for calculation.
+ If None, the current SOC is used.
+
+ Returns:
+ float: The dynamically calculated maximum charge power in watts.
+ """
+ if soc is None:
+ soc = self.current_soc
+
+ if soc < 70:
+ return self.max_charge_power_fix
+ elif soc >= 70 and soc < 95:
+ return max(500, self.max_charge_power_fix * ((95 - soc) / 35))
+ # return max(500, self.max_charge_power_fix * (1 - (soc / 100)**2))
+ else: # SOC >= 95
+ return 500
+
def start_update_service(self):
"""
Starts the background thread to periodically update the state.
@@ -203,7 +246,7 @@ def _update_state_loop(self):
self.battery_request_current_soc()
except (requests.exceptions.RequestException, ValueError, KeyError) as e:
logger.error("[BATTERY-IF] Error while updating state: %s", e)
- # Break the sleep interval into smaller chunks to allow immediate shutdown
+ # Break the sleep interval into smaller chunks to allow immediate shutdown
sleep_interval = self.update_interval
while sleep_interval > 0:
if self._stop_event.is_set():
diff --git a/src/web/index.html b/src/web/index.html
index 907e97e6..df2f79a3 100644
--- a/src/web/index.html
+++ b/src/web/index.html
@@ -72,8 +72,8 @@