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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/batcontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,8 @@ def run(self):
TIME_BETWEEN_EVALUATIONS
)
self.api_overwrite = False
if self.mqtt_api is not None:
self.mqtt_api.publish_api_override_active(False)
return

# Correction for time that has already passed in the current interval
Expand Down Expand Up @@ -914,6 +916,8 @@ def refresh_static_values(self) -> None:
self.min_price_difference_rel)
self.mqtt_api.publish_production_offset(
self.production_offset_percent)
self.mqtt_api.publish_api_override_active(
self.api_overwrite)
#
self.mqtt_api.publish_evaluation_intervall(
TIME_BETWEEN_EVALUATIONS)
Expand Down Expand Up @@ -941,6 +945,8 @@ def api_set_mode(self, mode: int):

logger.info('API: Setting mode to %s', mode)
self.api_overwrite = True
if self.mqtt_api is not None:
self.mqtt_api.publish_api_override_active(True)

if mode != self.last_mode:
if mode == MODE_FORCE_CHARGING:
Expand All @@ -966,6 +972,8 @@ def api_set_charge_rate(self, charge_rate: int):
return
logger.info('API: Setting charge rate to %d W', charge_rate)
self.api_overwrite = True
if self.mqtt_api is not None:
self.mqtt_api.publish_api_override_active(True)
if charge_rate != self.last_charge_rate:
self.force_charge(charge_rate)

Expand Down
23 changes: 23 additions & 0 deletions src/batcontrol/mqtt_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- /min_price_difference : minimum price difference in EUR
- /discharge_blocked : bool # Discharge is blocked by other sources
- /production_offset: production offset percentage (1.0 = 100%, 0.8 = 80%, etc.)
- /api_override_active: bool indicating whether a temporary external/API override is active

The following statistical arrays are published as JSON arrays:
- /FCST/production: forecasted production in W
Expand Down Expand Up @@ -451,6 +452,17 @@ def publish_production_offset(self, production_offset: float) -> None:
f'{production_offset:.3f}'
)

def publish_api_override_active(self, active: bool) -> None:
""" Publish whether a temporary API override is currently active.
/api_override_active
"""
if self.client.is_connected():
self.client.publish(
self.base_topic + '/api_override_active',
str(active).lower(),
retain=True
)

def publish_peak_shaving_enabled(self, enabled: bool) -> None:
""" Publish peak shaving enabled status to MQTT
/peak_shaving/enabled
Expand Down Expand Up @@ -614,6 +626,17 @@ def send_mqtt_discovery_messages(self) -> None:
"W",
self.base_topic + "/peak_shaving/charge_limit")

# sensors
self.publish_mqtt_discovery_message(
"API Override Active",
"batcontrol_api_override_active",
"binary_sensor",
None,
None,
self.base_topic + "/api_override_active",
entity_category="diagnostic",
value_template="{% if value == 'true' %}ON{% else %}OFF{% endif %}")

# sensors
self.publish_mqtt_discovery_message(
"Discharge Blocked",
Expand Down
131 changes: 130 additions & 1 deletion tests/batcontrol/test_core.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Tests for core batcontrol functionality including MODE_LIMIT_BATTERY_CHARGE_RATE"""
import datetime
import pytest
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch

from batcontrol.core import (
Batcontrol,
MODE_ALLOW_DISCHARGING,
MODE_LIMIT_BATTERY_CHARGE_RATE,
)
from batcontrol.logic.logic import Logic as LogicFactory
Expand Down Expand Up @@ -516,6 +517,134 @@ def test_run_dispatches_limit_battery_charge_rate(self, run_dispatch_setup):
mock_inverter.set_mode_avoid_discharge.assert_not_called()


class TestApiOverrideMqttState:
"""API override state should be observable via MQTT."""

@pytest.fixture
def mock_config(self):
return {
'timezone': 'Europe/Berlin',
'time_resolution_minutes': 60,
'inverter': {
'type': 'dummy',
'max_grid_charge_rate': 5000,
'max_pv_charge_rate': 3000,
'min_pv_charge_rate': 0,
},
'utility': {
'type': 'tibber',
'apikey': 'test_token'
},
'pvinstallations': [],
'consumption_forecast': {
'type': 'simple',
'value': 500
},
'battery_control': {
'max_charging_from_grid_limit': 0.8,
'min_price_difference': 0.05
},
'mqtt': {'enabled': False}
}

@pytest.fixture
def run_dispatch_setup(self, mock_config, mocker):
core_module = "batcontrol.core"

mock_inverter = mocker.MagicMock()
mock_inverter.max_pv_charge_rate = 3000
mock_inverter.max_grid_charge_rate = 5000
mock_inverter.get_max_capacity.return_value = 10000
mock_inverter.get_SOC.return_value = 50
mock_inverter.get_stored_energy.return_value = 5000
mock_inverter.get_stored_usable_energy.return_value = 4500
mock_inverter.get_free_capacity.return_value = 5000

mock_tariff_provider = mocker.MagicMock()
mock_tariff_provider.get_prices.return_value = {0: 0.20, 1: 0.30, 2: 0.25}
mock_tariff_provider.refresh_data = mocker.MagicMock()

mock_solar_provider = mocker.MagicMock()
mock_solar_provider.get_forecast.return_value = {0: 0, 1: 0, 2: 0}
mock_solar_provider.refresh_data = mocker.MagicMock()

mock_consumption_provider = mocker.MagicMock()
mock_consumption_provider.get_forecast.return_value = {0: 500, 1: 500, 2: 500}
mock_consumption_provider.refresh_data = mocker.MagicMock()

fake_logic = mocker.MagicMock()
fake_logic.calculate.return_value = True
fake_logic.get_calculation_output.return_value = mocker.MagicMock(
reserved_energy=0,
required_recharge_energy=0,
min_dynamic_price_difference=0.05,
)
fake_logic.get_inverter_control_settings.return_value = mocker.MagicMock(
allow_discharge=True,
charge_from_grid=False,
charge_rate=0,
limit_battery_charge_rate=-1,
)

mocker.patch(
f"{core_module}.tariff_factory.create_tarif_provider",
autospec=True,
return_value=mock_tariff_provider,
)
mocker.patch(
f"{core_module}.inverter_factory.create_inverter",
autospec=True,
return_value=mock_inverter,
)
mocker.patch(
f"{core_module}.solar_factory.create_solar_provider",
autospec=True,
return_value=mock_solar_provider,
)
mocker.patch(
f"{core_module}.consumption_factory.create_consumption",
autospec=True,
return_value=mock_consumption_provider,
)
mocker.patch(
f"{core_module}.LogicFactory.create_logic",
autospec=True,
return_value=fake_logic,
)

bc = Batcontrol(mock_config)

yield bc, mock_inverter, fake_logic

bc.shutdown()

def test_api_set_mode_publishes_override_active(self, run_dispatch_setup):
bc, _mock_inverter, _fake_logic = run_dispatch_setup
bc.mqtt_api = MagicMock()

bc.api_set_mode(MODE_ALLOW_DISCHARGING)

bc.mqtt_api.publish_api_override_active.assert_called_once_with(True)

def test_api_set_charge_rate_publishes_override_active(self, run_dispatch_setup):
bc, _mock_inverter, _fake_logic = run_dispatch_setup
bc.mqtt_api = MagicMock()

bc.api_set_charge_rate(1200)

bc.mqtt_api.publish_api_override_active.assert_called_once_with(True)

def test_run_clears_override_and_publishes_inactive_state(self, run_dispatch_setup):
bc, _mock_inverter, _fake_logic = run_dispatch_setup
bc.mqtt_api = MagicMock()
bc.api_overwrite = True

bc.run()

assert bc.api_overwrite is False
assert bc.mqtt_api.publish_api_override_active.call_args_list[-1] == call(False)


class TestEvccPeakShavingGuard:
"""Test evcc peak shaving guard in core.py run loop."""

Expand Down
27 changes: 27 additions & 0 deletions tests/batcontrol/test_mqtt_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,33 @@ def test_mode_discovery_includes_limit_battery_charge_mode(self):
assert "{% elif value == 'Limit Battery Charge' %}8" in command_template


class TestDiscoveryMessages:
"""Discovery should expose key externally visible runtime state."""

def test_discovery_includes_api_override_active_binary_sensor(self):
api = MagicMock(spec=MqttApi)
api.base_topic = 'batcontrol'
api.publish_mqtt_discovery_message = MagicMock()
api.send_mqtt_discovery_for_mode = MagicMock()
api.send_mqtt_discovery_messages = (
MqttApi.send_mqtt_discovery_messages.__get__(api, MqttApi)
)

api.send_mqtt_discovery_messages()

assert any(
call.args[:3] == (
'API Override Active',
'batcontrol_api_override_active',
'binary_sensor',
)
and call.args[5] == 'batcontrol/api_override_active'
and call.kwargs['entity_category'] == 'diagnostic'
and "value == 'true'" in call.kwargs['value_template']
for call in api.publish_mqtt_discovery_message.call_args_list
Comment thread
MaStr marked this conversation as resolved.
)


class TestPeakShavingEnabledApi:
"""Regression test: peak_shaving/enabled must correctly parse the bytes payload."""

Expand Down