Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9169b5c
feat: get price_euro_per_wh_accu from home-assistant/openhab
rockinglama Dec 1, 2025
d5d75ce
Add charge rate limiting for AC and DC in change_control_state functi…
ohAnd Dec 7, 2025
fed363e
[AUTO] Update version to 0.2.29.202-develop
ohAnd Dec 7, 2025
3166b57
Fix AC charge power calculation during override in get_current_ac_cha…
ohAnd Dec 14, 2025
0ca9b53
[AUTO] Update version to 0.2.29.207-develop
ohAnd Dec 14, 2025
8ebb1e9
Implement dynamic max charge power based on charging curve configurat…
ohAnd Dec 14, 2025
eae0669
Enhance discharge state handling by introducing effective discharge l…
ohAnd Dec 15, 2025
485b4f8
[AUTO] Update version to 0.2.29.208-develop
ohAnd Dec 15, 2025
e9131dc
Improve error handling in OptimizationScheduler and PvInterface for r…
ohAnd Dec 19, 2025
8aa8ff9
Enhance error handling in EVOptBackend for response validation and in…
ohAnd Dec 19, 2025
3cd2d85
Enhance clipboard functionality in BugReportManager for improved iOS …
ohAnd Dec 20, 2025
a875c8d
Refactor charge demand calculations in BaseControl to use optimizatio…
ohAnd Dec 20, 2025
db8dca9
[AUTO] Update version to 0.2.29.209-develop
ohAnd Dec 20, 2025
61333fc
Add defensive handling for price of stored energy in EVOptBackend
ohAnd Dec 20, 2025
37a736e
refactor: update battery price configuration and unify data fetching …
ohAnd Dec 20, 2025
c96e6b7
[AUTO] Update version to 0.2.29.211-develop
ohAnd Dec 20, 2025
20f42a8
feat: get price_euro_per_wh_accu from home-assistant/openhab
rockinglama Dec 1, 2025
be3154b
refactor: update battery price configuration and unify data fetching …
ohAnd Dec 20, 2025
f428ca3
Merge branch 'feature/price_euro_per_wh_accu_sensor' of https://githu…
ohAnd Dec 20, 2025
f5616b4
fix: adjust pull request triggers for Docker workflows
ohAnd Dec 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/docker_develop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ name: Develop - build image and push
on:
push:
branches: ["develop"]
pull_request:
branches: ["develop"]
# pull_request:
# branches: ["develop"]
workflow_dispatch: # allows manual triggering of the workflow

env:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/docker_feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ on:
branches-ignore:
- "main"
- "develop"
pull_request:
branches: ["develop"]
workflow_dispatch: # allows manual triggering of the workflow

jobs:
Expand All @@ -21,7 +23,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: "3.11"

- name: Install dependencies
run: |
Expand Down
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ All endpoints return JSON and can be accessed via HTTP requests.

| Endpoint | Method | Returns / Accepts | Description |
| ----------------------------------- | ------ | ----------------- | -------------------------------------------------------------- |
| `/json/current_controls.json` | GET | JSON | Current system control states (AC/DC charge, mode, etc.) |
| `/json/current_controls.json` | GET | JSON | Current system control states (AC/DC charge, mode, discharge state, etc.) - reflects final combined state after all overrides |
| `/json/optimize_request.json` | GET | JSON | Last optimization request sent to EOS |
| `/json/optimize_response.json` | GET | JSON | Last optimization response from EOS |
| `/json/optimize_request.test.json` | GET | JSON | Test optimization request (static file) |
Expand Down Expand Up @@ -420,12 +420,26 @@ Get current system control states and battery information.
"api_version": "0.0.1"
}
```

**Important Notes:**
- **`current_discharge_allowed`**: This field reflects the **final effective state** after all overrides (EVCC modes, manual overrides) are applied. For example:
- When EVCC is charging in PV mode (`"inverter_mode": "MODE DISCHARGE ALLOWED EVCC PV"`), `current_discharge_allowed` will be `true` even if the optimizer originally suggested avoiding discharge
- This ensures consistency between the mode and discharge state for integrations like Home Assistant
- **Inverter modes**:
- `0` = MODE CHARGE FROM GRID
- `1` = MODE AVOID DISCHARGE
- `2` = MODE DISCHARGE ALLOWED
- `3` = MODE AVOID DISCHARGE EVCC FAST (fast charging)
- `4` = MODE DISCHARGE ALLOWED EVCC PV (EV charging in PV mode)
- `5` = MODE DISCHARGE ALLOWED EVCC MIN+PV (EV charging in Min+PV mode)
- `6` = MODE CHARGE FROM GRID EVCC FAST (grid charging during fast EV charge)

</details>

---

<details>
<summary>Show Example: <code>/json/optimize_request.json</code> (GET)</summary>
<parameter name="summary">Show Example: <code>/json/optimize_request.json</code> (GET)

Get the last optimization request sent to EOS.

Expand Down Expand Up @@ -822,7 +836,7 @@ EOS Connect publishes a wide range of real-time system data and control states t
| `status` | `myhome/eos_connect/status` | String (`"online"`) | Always set to `"online"` |
| `control/eos_ac_charge_demand` | `myhome/eos_connect/control/eos_ac_charge_demand` | Integer (W) | AC charge demand |
| `control/eos_dc_charge_demand` | `myhome/eos_connect/control/eos_dc_charge_demand` | Integer (W) | DC charge demand |
| `control/eos_discharge_allowed` | `myhome/eos_connect/control/eos_discharge_allowed` | Boolean | Discharge allowed |
| `control/eos_discharge_allowed` | `myhome/eos_connect/control/eos_discharge_allowed` | Boolean | Discharge allowed (final effective state after all overrides) |



Expand All @@ -847,6 +861,7 @@ You can use any MQTT client, automation platform, or dashboard tool to subscribe
- The `<mqtt_configured_prefix>` is set in your configuration file (see `config.yaml`).
- Some topics (e.g., inverter special values) are only published if the corresponding hardware is present and enabled.
- All topics are published with real-time updates as soon as new data is available.
- **State Consistency**: The `control/eos_discharge_allowed` topic reflects the **final effective state** after combining optimizer output, EVCC overrides, and manual overrides. This ensures that all outputs (MQTT, Web API, inverter commands) consistently represent EOS_connect's final decision.

</details>
</br>
Expand Down
7 changes: 6 additions & 1 deletion src/CONFIG_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,13 @@ A default config file will be created with the first start, if there is no `conf
- **`battery.max_soc_percentage`**:
Maximum state of charge for the battery, as a percentage.

- **`price_euro_per_wh_accu`**:
- **`battery.price_euro_per_wh_accu`**:
Price for battery in €/Wh - can be used to shift the result over the day according to the available energy (more details follow).


- **`battery.price_euro_per_wh_sensor`**:
Sensor/item identifier that exposes the battery price in €/Wh. If `battery.source` is set to `homeassistant` or `openhab` and a sensor/item is configured here, the system will fetch the value from that sensor/item. If no sensor is configured, the static value at `price_euro_per_wh_accu` will be used. For Home Assistant use an entity ID (e.g., `sensor.battery_price`); for OpenHAB use an item name (e.g., `BatteryPrice`).

- **`battery.charging_curve_enabled`**:
Enables or disables the dynamic charging curve for the battery.
- `true`: The system will automatically reduce the maximum charging power as the battery SOC increases, helping to protect battery health and optimize efficiency.
Expand Down Expand Up @@ -471,6 +475,7 @@ battery:
min_soc_percentage: 5 # URL for battery soc in %
max_soc_percentage: 100 # URL for battery soc in %
price_euro_per_wh_accu: 0 # price for battery in €/Wh
price_euro_per_wh_sensor: "" # Home Assistant entity (e.g. sensor.battery_price) providing €/Wh
charging_curve_enabled: true # enable dynamic charging curve for battery
# List of PV forecast source configuration
pv_forecast_source:
Expand Down
5 changes: 5 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def create_default_config(self):
"min_soc_percentage": 5,
"max_soc_percentage": 100,
"price_euro_per_wh_accu": 0.0, # price for battery in euro/Wh
"price_euro_per_wh_sensor": "", # sensor/item providing battery energy cost in €/Wh
"charging_curve_enabled": True, # enable charging curve
}
),
Expand Down Expand Up @@ -281,6 +282,10 @@ def create_default_config(self):
config["battery"].yaml_add_eol_comment(
"price for battery in euro/Wh - default: 0.0", "price_euro_per_wh_accu"
)
config["battery"].yaml_add_eol_comment(
"sensor/item providing the battery price (€/Wh) - HA entity or OpenHAB item",
"price_euro_per_wh_sensor",
)
config["battery"].yaml_add_eol_comment(
"enabling charging curve for controlled charging power"
+ " according to the SOC (default: true)",
Expand Down
63 changes: 48 additions & 15 deletions src/eos_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,13 +493,25 @@ def get_ems_data(dst_change_detected):
"pv_prognose_wh": pv_prognose_wh,
"strompreis_euro_pro_wh": strompreis_euro_pro_wh,
"einspeiseverguetung_euro_pro_wh": einspeiseverguetung_euro_pro_wh,
"preis_euro_pro_wh_akku": config_manager.config["battery"][
"price_euro_per_wh_accu"
],
"preis_euro_pro_wh_akku": battery_interface.get_price_euro_per_wh(),
"gesamtlast": gesamtlast,
}

def get_pv_akku_data():
# Use dynamic max charge power if charging curve is enabled, otherwise use fixed value
# This ensures EVopt receives realistic charging limits based on current SOC
current_dynamic_max = battery_interface.get_max_charge_power()
max_charge_power = (
current_dynamic_max
if config_manager.config["battery"].get("charging_curve_enabled", True)
else config_manager.config["battery"]["max_charge_power_w"]
)

# Store this value in base_control so it can use the same value when
# converting relative charge demands back to absolute values
# This prevents sawtooth patterns caused by mismatched max_charge_power values
base_control.optimization_max_charge_power_w = max_charge_power

akku_object = {
"capacity_wh": config_manager.config["battery"]["capacity_wh"],
"charging_efficiency": config_manager.config["battery"][
Expand All @@ -508,9 +520,7 @@ def get_pv_akku_data():
"discharging_efficiency": config_manager.config["battery"][
"discharge_efficiency"
],
"max_charge_power_w": config_manager.config["battery"][
"max_charge_power_w"
],
"max_charge_power_w": max_charge_power,
"initial_soc_percentage": round(battery_interface.get_current_soc()),
"min_soc_percentage": battery_interface.get_min_soc(),
"max_soc_percentage": battery_interface.get_max_soc(),
Expand Down Expand Up @@ -638,6 +648,14 @@ def setting_control_data(ac_charge_demand_rel, dc_charge_demand_rel, discharge_a
base_control.set_current_ac_charge_demand(ac_charge_demand_rel)
base_control.set_current_dc_charge_demand(dc_charge_demand_rel)
base_control.set_current_discharge_allowed(bool(discharge_allowed))

# set the current battery state of charge
base_control.set_current_battery_soc(battery_interface.get_current_soc())
# getting the current charging state from evcc
base_control.set_current_evcc_charging_state(evcc_interface.get_charging_state())
base_control.set_current_evcc_charging_mode(evcc_interface.get_charging_mode())

# Publish MQTT after all states are set to reflect the final combined state
mqtt_interface.update_publish_topics(
{
"control/eos_ac_charge_demand": {
Expand All @@ -647,15 +665,10 @@ def setting_control_data(ac_charge_demand_rel, dc_charge_demand_rel, discharge_a
"value": base_control.get_current_dc_charge_demand()
},
"control/eos_discharge_allowed": {
"value": base_control.get_current_discharge_allowed()
"value": base_control.get_effective_discharge_allowed()
},
}
)
# set the current battery state of charge
base_control.set_current_battery_soc(battery_interface.get_current_soc())
# getting the current charging state from evcc
base_control.set_current_evcc_charging_state(evcc_interface.get_charging_state())
base_control.set_current_evcc_charging_mode(evcc_interface.get_charging_mode())

last_control_data["current_soc"] = current_soc
last_control_data["ac_charge_demand"] = ac_charge_demand_rel
Expand Down Expand Up @@ -862,8 +875,25 @@ def __run_optimization_loop(self):
optimized_response, avg_runtime = eos_interface.optimize(
json_optimize_input, config_manager.config["eos"]["timeout"]
)
# Store the runtime for use in sleep calculation
self._last_avg_runtime = avg_runtime
# Store the runtime for use in sleep calculation (defensive against None)
try:
if avg_runtime is None:
# keep previous value or default if not present
self._last_avg_runtime = getattr(self, "_last_avg_runtime", 120)
logger.warning(
"[Main] optimize() returned no avg_runtime; keeping previous value: %s",
self._last_avg_runtime,
)
else:
self._last_avg_runtime = avg_runtime
except (TypeError, AttributeError) as e:
# fallback to a sensible default and log the specific error
logger.warning(
"[Main] Error processing avg_runtime (%s): %s. Falling back to default.",
type(avg_runtime).__name__ if "avg_runtime" in locals() else "Unknown",
e,
)
self._last_avg_runtime = 120

json_optimize_input["timestamp"] = datetime.now(time_zone).isoformat()
self.last_request_response["request"] = json.dumps(
Expand Down Expand Up @@ -1177,10 +1207,12 @@ def change_control_state():
tgt_ac_charge_power = min(
base_control.get_needed_ac_charge_power(),
round(battery_interface.get_max_charge_power()),
config_manager.config["inverter"]["max_grid_charge_rate"],
)
tgt_dc_charge_power = min(
base_control.get_current_dc_charge_demand(),
round(battery_interface.get_max_charge_power()),
config_manager.config["inverter"]["max_pv_charge_rate"],
)

base_control.set_current_bat_charge_max(
Expand Down Expand Up @@ -1431,7 +1463,8 @@ def get_controls():
"""
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()
# Use effective discharge allowed state (reflects final state after EVCC/manual overrides)
current_discharge_allowed = base_control.get_effective_discharge_allowed()
current_battery_soc = battery_interface.get_current_soc()
base_control.set_current_battery_soc(current_battery_soc)
current_inverter_mode = base_control.get_current_overall_state()
Expand Down
53 changes: 43 additions & 10 deletions src/interfaces/base_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ def __init__(self, config, timezone, time_frame_base):
self.current_battery_soc = 0
self.time_zone = timezone
self.config = config
# Track the max_charge_power_w value used in the last optimization request
# to ensure consistent conversion of relative charge values
self.optimization_max_charge_power_w = config["battery"]["max_charge_power_w"]
self._state_change_timestamps = []
self.update_interval = 15 # seconds
self._update_thread = None
Expand Down Expand Up @@ -118,6 +121,23 @@ def get_current_discharge_allowed(self):
"""
return self.current_discharge_allowed

def get_effective_discharge_allowed(self):
"""
Returns the effective discharge allowed state based on the final overall state.
This reflects the FINAL state after all overrides (EVCC, manual) are applied.

Returns:
bool: True if discharge is allowed in the current effective state, False otherwise.
"""
# Modes where discharge is explicitly allowed
discharge_allowed_modes = [
MODE_DISCHARGE_ALLOWED, # 2: Normal discharge allowed
MODE_DISCHARGE_ALLOWED_EVCC_PV, # 4: EVCC PV mode (discharge to support EV)
MODE_DISCHARGE_ALLOWED_EVCC_MIN_PV, # 5: EVCC Min+PV mode (discharge to support EV)
]

return self.current_overall_state in discharge_allowed_modes

def get_current_overall_state(self):
"""
Returns the current overall state.
Expand Down Expand Up @@ -170,11 +190,11 @@ def get_override_duration(self):
def set_current_ac_charge_demand(self, value_relative):
"""
Sets the current AC charge demand.
Uses the optimization_max_charge_power_w to convert relative values
to ensure consistency with the value sent to the optimizer.
"""
current_hour = datetime.now(self.time_zone).hour
current_charge_demand = (
value_relative * self.config["battery"]["max_charge_power_w"]
)
current_charge_demand = value_relative * self.optimization_max_charge_power_w
if current_charge_demand == self.current_ac_charge_demand:
# No change, so do not log
return
Expand All @@ -184,10 +204,10 @@ def set_current_ac_charge_demand(self, value_relative):
self.current_ac_charge_demand = current_charge_demand
logger.debug(
"[BASE-CTRL] set AC charge demand for current hour %s:00 -> %s Wh -"
+ " based on max charge power %s W",
+ " based on optimization max charge power %s W",
current_hour,
self.current_ac_charge_demand,
self.config["battery"]["max_charge_power_w"],
self.optimization_max_charge_power_w,
)
elif self.override_active_since > time.time() - 2:
# self.current_ac_charge_demand = (
Expand All @@ -205,11 +225,11 @@ def set_current_ac_charge_demand(self, value_relative):
def set_current_dc_charge_demand(self, value_relative):
"""
Sets the current DC charge demand.
Uses the optimization_max_charge_power_w to convert relative values
to ensure consistency with the value sent to the optimizer.
"""
current_hour = datetime.now(self.time_zone).hour
current_charge_demand = (
value_relative * self.config["battery"]["max_charge_power_w"]
)
current_charge_demand = value_relative * self.optimization_max_charge_power_w
if current_charge_demand == self.current_dc_charge_demand:
# logger.debug(
# "[BASE-CTRL] NO CHANGE DC charge demand for current hour %s:00 "+
Expand All @@ -226,10 +246,10 @@ def set_current_dc_charge_demand(self, value_relative):
self.current_dc_charge_demand = current_charge_demand
logger.debug(
"[BASE-CTRL] set DC charge demand for current hour %s:00 -> %s Wh -"
+ " based on max charge power %s W",
+ " based on optimization max charge power %s W",
current_hour,
self.current_dc_charge_demand,
self.config["battery"]["max_charge_power_w"],
self.optimization_max_charge_power_w,
)
else:
logger.debug(
Expand Down Expand Up @@ -299,7 +319,20 @@ def get_needed_ac_charge_power(self):
"""
Calculates the required AC charge power to deliver the target energy
within the remaining time frame.

During normal EOS operation: Converts energy (Wh) stored in current_ac_charge_demand
to power (W) based on remaining time in the current time frame.

During override: Returns current_ac_charge_demand directly as it's already
set as power (W), not energy (Wh).

This fixes issue #173 where override values were incorrectly converted.
"""
# During override, current_ac_charge_demand is already in W, return it directly
if self.override_active:
return self.current_ac_charge_demand

# Normal EOS operation: convert energy (Wh) to power (W)
current_time = datetime.now(self.time_zone)
# Calculate the seconds elapsed in the current time frame with time_frame_base
seconds_elapsed = (
Expand Down
Loading
Loading