diff --git a/README.md b/README.md index 9787d36..e24489c 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,20 @@ The key-value pairs in the JSON express the following: | chargeStateTopic | topic indicating the charge state - **required** | | chargingValue | payload that indicates the charging - **required** | | socTopic | topic where the gateway publishes the SoC for the charging station - optional | +| socTsTopic | topic where the gateway publishes the SoC timestamp for the charging station - optional | | rangeTopic | topic where the gateway publishes the range for the charging station - optional | | chargerConnectedTopic | topic indicating that the vehicle is connected to the charging station - optional | | chargerConnectedValue | payload that indicates that the charger is connected - optional | | vin | vehicle identification number to map the charging station information to a vehicle - **required** | +­| importedEnergyTopic | topic where the charging station publishes the amount of imported energy (in Wh) - optional | + +#### `importedEnergyTopic` + +This topic provides the amount of energy imported by the charging station, measured in watt-hours. + +During the charging process, the system normally calculates the vehicle status refresh interval based on the current charging power and the vehicle’s battery capacity. However, this estimation can become inaccurate during PV surplus charging, where the charging power fluctuates significantly. + +If the charging station can report the total imported energy (for example, the `daily_imported` topic provided by openWB), this value can be used instead to determine when to refresh the vehicle status. This approach ensures more reliable updates even under variable charging conditions. ### Advanced settings diff --git a/examples/charging-stations.json.sample_openWB_2.0 b/examples/charging-stations.json.sample_openWB_2.0 index 544f93b..023de94 100644 --- a/examples/charging-stations.json.sample_openWB_2.0 +++ b/examples/charging-stations.json.sample_openWB_2.0 @@ -2,19 +2,23 @@ { "chargeStateTopic": "openWB/chargepoint/2/get/charge_state", "chargingValue": "true", - "socTopic": "openWB/set/vehicle/2/get/soc", - "rangeTopic": "openWB/set/vehicle/2/get/range", + "socTopic": "openWB/set/mqtt/vehicle/2/get/soc", + "socTsTopic": "openWB/set/mqtt/vehicle/2/get/soc_timestamp", + "rangeTopic": "openWB/set/mqtt/vehicle/2/get/range", "chargerConnectedTopic": "openWB/chargepoint/2/get/plug_state", "chargerConnectedValue": "true", + "importedEnergyTopic": "openWB/chargepoint/2/get/daily_imported", "vin": "vin1" }, { "chargeStateTopic": "openWB/chargepoint/3/get/charge_state", "chargingValue": "true", - "socTopic": "openWB/set/vehicle/3/get/soc", - "rangeTopic": "openWB/set/vehicle/3/get/range", + "socTopic": "openWB/set/mqtt/vehicle/3/get/soc", + "socTsTopic": "openWB/set/mqtt/vehicle/3/get/soc_timestamp", + "rangeTopic": "openWB/set/mqtt/vehicle/3/get/range", "chargerConnectedTopic": "openWB/chargepoint/3/get/plug_state", "chargerConnectedValue": "true", + "importedEnergyTopic": "openWB/chargepoint/2/get/daily_imported", "vin": "vin2" } ] diff --git a/poetry.lock b/poetry.lock index 2899fdd..61814c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "anyio" @@ -217,6 +217,21 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] +[[package]] +name = "freezegun" +version = "1.5.5" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, + {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "gmqtt" version = "0.7.0" @@ -675,6 +690,21 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.1.1" @@ -737,6 +767,18 @@ httpx = ">=0.27.0,<0.29.0" pycryptodome = ">=3.20.0,<4.0.0" tenacity = ">=9.0.0,<10.0.0" +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -824,4 +866,4 @@ devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3) [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "f78ad950e86b99428d134a345e435585c43bf2bbef1d53363cb45981c281631c" +content-hash = "4f7392baf839ed8a0b7cc404b94a5e4c29bdf7074521b5c3973965b8e36c7a4a" diff --git a/pyproject.toml b/pyproject.toml index 3c803f3..3b84b38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ pytest-asyncio = "^1.2.0" pytest-mock = "^3.14.0" mypy = "^1.15.0" pylint = "^3.3.6" +freezegun = "^1.5.5" [tool.poetry.dependencies] saic-ismart-client-ng = { develop = true } diff --git a/src/configuration/parser.py b/src/configuration/parser.py index 6ceffba..5e0a410 100644 --- a/src/configuration/parser.py +++ b/src/configuration/parser.py @@ -550,21 +550,24 @@ def __process_charging_stations_file(config: Configuration, json_file: str) -> N for item in data: charge_state_topic = item["chargeStateTopic"] charging_value = item["chargingValue"] + soc_topic = item.get("socTopic", None) + soc_ts_topic = item.get("socTsTopic", None) vin = item["vin"] - if "socTopic" in item: - charging_station = ChargingStation( - vin, charge_state_topic, charging_value, item["socTopic"] - ) - else: - charging_station = ChargingStation( - vin, charge_state_topic, charging_value - ) + charging_station = ChargingStation( + vin, + charge_state_topic, + charging_value, + soc_topic, + soc_ts_topic, + ) if "rangeTopic" in item: charging_station.range_topic = item["rangeTopic"] if "chargerConnectedTopic" in item: charging_station.connected_topic = item["chargerConnectedTopic"] if "chargerConnectedValue" in item: charging_station.connected_value = item["chargerConnectedValue"] + if "importedEnergyTopic" in item: + charging_station.imported_energy_topic = item["importedEnergyTopic"] config.charging_stations_by_vin[vin] = charging_station except FileNotFoundError: LOG.warning(f"File {json_file} does not exist") diff --git a/src/handlers/vehicle.py b/src/handlers/vehicle.py index 58f394e..0badff9 100644 --- a/src/handlers/vehicle.py +++ b/src/handlers/vehicle.py @@ -17,6 +17,7 @@ import mqtt_topics from saic_api_listener import MqttGatewayAbrpListener, MqttGatewayOsmAndListener from status_publisher.vehicle_info import VehicleInfoPublisher +from vehicle import RefreshMode if TYPE_CHECKING: from saic_ismart_client_ng import SaicApi @@ -308,6 +309,18 @@ def __setup_ha_discovery( return HomeAssistantDiscovery(vehicle_state, vin_info, config) return None + async def handle_charging_station_energy_imported(self, energy: float) -> None: + if self.openwb_integration is None: + return + if self.openwb_integration.should_refresh_by_imported_energy( + energy, self.vehicle_state.charge_polling_min_percent, self.vin_info.vin + ): + LOG.info( + f"Triggering refresh due to imported energy {energy} Wh for vehicle {self.vin_info.vin}" + ) + self.vehicle_state.set_refresh_mode( + RefreshMode.FORCE, "imported energy threshold reached" + ) class VehicleHandlerLocator(ABC): def get_vehicle_handler(self, vin: str) -> VehicleHandler | None: diff --git a/src/integrations/openwb/__init__.py b/src/integrations/openwb/__init__.py index ca84f28..90e6f43 100644 --- a/src/integrations/openwb/__init__.py +++ b/src/integrations/openwb/__init__.py @@ -1,6 +1,8 @@ from __future__ import annotations +import datetime import logging +import math from typing import TYPE_CHECKING import extractors @@ -29,18 +31,24 @@ def __init__( ) -> None: self.__charging_station = charging_station self.__publisher = publisher + self.charger_connected: bool | None = None + self.real_total_battery_capacity: float | None = None + self.last_imported_energy_wh: float | None = None + self.computed_refresh_by_imported_energy_wh: float | None = None def update_openwb( self, vehicle_status: VehicleStatusRespProcessingResult, charge_status: ChrgMgmtDataRespProcessingResult | None, ) -> None: + if charge_status: + self.real_total_battery_capacity = charge_status.real_total_battery_capacity range_topic = self.__charging_station.range_topic electric_range = extractors.extract_electric_range( vehicle_status, charge_status ) if electric_range is not None and range_topic is not None: - LOG.info("OpenWB Integration published range to %f", range_topic) + LOG.info("OpenWB Integration published range to %s", range_topic) self.__publisher.publish_float( key=range_topic, value=electric_range, @@ -56,3 +64,86 @@ def update_openwb( value=soc, no_prefix=True, ) + + soc_ts_topic = self.__charging_station.soc_ts_topic + soc_ts = int(datetime.datetime.now().timestamp()) + if soc_ts_topic is not None: + LOG.info("OpenWB Integration published SoC timestamp to %s", soc_ts_topic) + self.__publisher.publish_int( + key=soc_ts_topic, + value=soc_ts, + no_prefix=True, + ) + + def set_charger_connection_state(self, connected: bool, vin: str) -> None: + if self.charger_connected == connected: + # No change, do nothing + return + + self.charger_connected = connected + if self.charger_connected: + LOG.info("OpenWB Integration: Charger connected to vehicle %s", vin) + else: + LOG.info("OpenWB Integration: Charger disconnected from vehicle %s", vin) + + def should_refresh_by_imported_energy( + self, imported_energy_wh: float, charge_polling_min_percent: float, vin: str + ) -> bool: + """Determine if the vehicle status should be refreshed based on imported energy. + + The method triggers a refresh if the imported energy since the last refresh + exceeds a threshold based on battery capacity and a minimum percentage. + If the imported energy decreases (e.g., daily reset), the threshold is recalculated. + """ + # Charger connection state might not be available. If disconnected, skip the check. + if self.charger_connected is False: + LOG.debug( + f"Charger for vehicle {vin} is disconnected. Skipping imported energy check." + ) + return False + # Return False if battery capacity is not available + if self.real_total_battery_capacity is None: + LOG.warning( + "Battery capacity not available. Cannot calculate energy per percent." + ) + return False + + # Calculate the energy corresponding to 1% of the battery in Wh + energy_per_percent = (self.real_total_battery_capacity * 1000) / 100.0 + + # Minimum energy threshold for triggering a refresh + energy_for_min_pct = math.ceil(charge_polling_min_percent * energy_per_percent) + + # Initialize the refresh threshold if it hasn't been set yet + if not self.computed_refresh_by_imported_energy_wh or imported_energy_wh < ( + self.last_imported_energy_wh or 0 + ): + self.computed_refresh_by_imported_energy_wh = ( + imported_energy_wh + energy_for_min_pct + ) + LOG.debug( + f"Initial or reset threshold for vehicle {vin} set to " + f"{self.computed_refresh_by_imported_energy_wh} Wh" + ) + + # Check if the imported energy exceeds the threshold + refresh_needed = False + if imported_energy_wh >= self.computed_refresh_by_imported_energy_wh: + LOG.info( + f"OpenWB Integration: Imported energy threshold of {self.computed_refresh_by_imported_energy_wh} Wh reached " + f"(current: {imported_energy_wh} Wh). Triggering vehicle refresh." + ) + refresh_needed = True + + # Calculate the next threshold + self.computed_refresh_by_imported_energy_wh = ( + imported_energy_wh + energy_for_min_pct + ) + LOG.debug( + f"Next imported energy threshold for vehicle {vin} set to " + f"{self.computed_refresh_by_imported_energy_wh} Wh" + ) + + # Save the last imported energy value + self.last_imported_energy_wh = imported_energy_wh + return refresh_needed diff --git a/src/integrations/openwb/charging_station.py b/src/integrations/openwb/charging_station.py index b02542c..bb42e74 100644 --- a/src/integrations/openwb/charging_station.py +++ b/src/integrations/openwb/charging_station.py @@ -8,11 +8,18 @@ def __init__( charge_state_topic: str, charging_value: str, soc_topic: str | None = None, + soc_ts_topic: str | None = None, + range_topic: str | None = None, + connected_topic: str | None = None, + connected_value: str | None = None, + imported_energy_topic: str | None = None, ) -> None: self.vin: str = vin self.charge_state_topic: str = charge_state_topic self.charging_value: str = charging_value self.soc_topic: str | None = soc_topic - self.range_topic: str | None = None - self.connected_topic: str | None = None - self.connected_value: str | None = None + self.soc_ts_topic: str | None = soc_ts_topic + self.range_topic: str | None = range_topic + self.connected_topic: str | None = connected_topic + self.connected_value: str | None = connected_value + self.imported_energy_topic: str | None = imported_energy_topic diff --git a/src/mqtt_gateway.py b/src/mqtt_gateway.py index 844a5ae..c30ef5d 100644 --- a/src/mqtt_gateway.py +++ b/src/mqtt_gateway.py @@ -207,6 +207,30 @@ async def on_charging_detected(self, vin: str) -> None: else: LOG.debug(f"Charging detected for unknown vin {vin}") + @override + async def on_charging_station_energy_imported( + self, vin: str, energy: float + ) -> None: + vehicle_handler = self.get_vehicle_handler(vin) + if vehicle_handler: + await vehicle_handler.handle_charging_station_energy_imported(energy) + else: + LOG.debug(f"Imported energy for unknown vin {vin} received") + + @override + async def on_charger_connection_state_changed( + self, vin: str, connected: bool + ) -> None: + vehicle_handler = self.get_vehicle_handler(vin) + if not vehicle_handler: + LOG.debug(f"Connected state change for unknown vin {vin} received") + return + + integration = vehicle_handler.openwb_integration + if integration: + integration.set_charger_connection_state(connected, vin) + LOG.debug(f"Updated connected state for vehicle {vin} to {connected}") + @override async def on_mqtt_global_command_received( self, *, topic: str, payload: str diff --git a/src/publisher/core.py b/src/publisher/core.py index b0ffa66..072b1d9 100644 --- a/src/publisher/core.py +++ b/src/publisher/core.py @@ -30,6 +30,17 @@ async def on_mqtt_global_command_received( ) -> None: raise NotImplementedError("Should have implemented this") + @abstractmethod + async def on_charging_station_energy_imported( + self, vin: str, energy: float + ) -> None: + raise NotImplementedError("Should have implemented this") + @abstractmethod + async def on_charger_connection_state_changed( + self, vin: str, connected: bool + ) -> None: + raise NotImplementedError("Should have implemented this") + class Publisher(ABC): def __init__(self, config: Configuration) -> None: diff --git a/src/publisher/mqtt_publisher.py b/src/publisher/mqtt_publisher.py index cb2809a..5644f63 100644 --- a/src/publisher/mqtt_publisher.py +++ b/src/publisher/mqtt_publisher.py @@ -26,6 +26,7 @@ def __init__(self, configuration: Configuration) -> None: self.vin_by_charge_state_topic: dict[str, str] = {} self.last_charge_state_by_vin: dict[str, str] = {} self.vin_by_charger_connected_topic: dict[str, str] = {} + self.vin_by_imported_energy_topic: dict[str, str] = {} self.first_connection = True mqtt_client = gmqtt.Client( @@ -127,6 +128,14 @@ def enable_commands(self) -> None: charging_station.connected_topic ] = charging_station.vin self.client.subscribe(charging_station.connected_topic) + if charging_station.imported_energy_topic: + LOG.debug( + f"Subscribing to MQTT topic {charging_station.imported_energy_topic}" + ) + self.vin_by_imported_energy_topic[ + charging_station.imported_energy_topic + ] = charging_station.vin + self.client.subscribe(charging_station.imported_energy_topic) if self.configuration.ha_discovery_enabled: # enable dynamic discovery pushing in case ha reconnects self.client.subscribe(self.configuration.ha_lwt_topic) @@ -162,10 +171,24 @@ async def __on_message_real(self, *, topic: str, payload: str) -> None: LOG.debug( f"Vehicle with vin {vin} is connected to its charging station" ) + charger_connected = True else: LOG.debug( f"Vehicle with vin {vin} is disconnected from its charging station" ) + charger_connected = False + if self.command_listener is not None: + await self.command_listener.on_charger_connection_state_changed( + vin, charger_connected + ) + elif topic in self.vin_by_imported_energy_topic: + LOG.debug(f"Received message over topic {topic} with payload {payload}") + vin = self.vin_by_imported_energy_topic[topic] + LOG.info(f"Received imported energy message for vehicle with vin {vin}") + if self.command_listener is not None: + await self.command_listener.on_charging_station_energy_imported( + vin, float(payload) + ) elif topic == self.configuration.ha_lwt_topic: if self.command_listener is not None: await self.command_listener.on_mqtt_global_command_received( diff --git a/tests/integrations/openwb/test_openwb_integration.py b/tests/integrations/openwb/test_openwb_integration.py index 99d931e..04f1d3a 100644 --- a/tests/integrations/openwb/test_openwb_integration.py +++ b/tests/integrations/openwb/test_openwb_integration.py @@ -1,9 +1,12 @@ from __future__ import annotations +from datetime import datetime +import math from typing import Any import unittest from apscheduler.schedulers.blocking import BlockingScheduler +from freezegun import freeze_time import pytest from saic_ismart_client_ng.api.vehicle.schema import VinInfo @@ -25,7 +28,13 @@ RANGE_TOPIC = "/mock/range" CHARGE_STATE_TOPIC = "/mock/charge/state" SOC_TOPIC = "/mock/soc/state" +SOC_TS_TOPIC = "/mock/soc/timestamp" CHARGING_VALUE = "VehicleIsCharging" +IMPORTED_ENERGY_TOPIC = "/mock/imported_energy" +CAR_CAPACITY_KWH = 50.0 +CHARGE_POLLING_MIN_PERCENT = 5.0 +ENERGY_PER_PERCENT = CAR_CAPACITY_KWH * 1000.0 / 100.0 +ENERGY_FOR_MIN_PCT = math.ceil(CHARGE_POLLING_MIN_PERCENT * ENERGY_PER_PERCENT) class TestOpenWBIntegration(unittest.IsolatedAsyncioTestCase): @@ -46,13 +55,15 @@ def setUp(self) -> None: charge_state_topic=CHARGE_STATE_TOPIC, charging_value=CHARGING_VALUE, soc_topic=SOC_TOPIC, + soc_ts_topic=SOC_TS_TOPIC, + range_topic=RANGE_TOPIC, ) - charging_station.range_topic = RANGE_TOPIC self.openwb_integration = OpenWBIntegration( charging_station=charging_station, publisher=self.publisher, ) + @freeze_time("2025-01-01 12:00:00") async def test_update_soc_with_no_bms_data(self) -> None: vehicle_status_resp = get_mock_vehicle_status_resp() result = self.vehicle_state.handle_vehicle_status(vehicle_status_resp) @@ -65,16 +76,21 @@ async def test_update_soc_with_no_bms_data(self) -> None: SOC_TOPIC, float(DRIVETRAIN_SOC_VEHICLE), ) + self.assert_mqtt_topic( + SOC_TS_TOPIC, int(datetime(2025, 1, 1, 12, 0, 0).timestamp()) + ) self.assert_mqtt_topic( RANGE_TOPIC, DRIVETRAIN_RANGE_VEHICLE, ) expected_topics = { SOC_TOPIC, + SOC_TS_TOPIC, RANGE_TOPIC, } assert expected_topics == set(self.publisher.map.keys()) + @freeze_time("2025-01-01 12:00:00") async def test_update_soc_with_bms_data(self) -> None: vehicle_status_resp = get_mock_vehicle_status_resp() chrg_mgmt_data_resp = get_mock_charge_management_data_resp() @@ -98,12 +114,144 @@ async def test_update_soc_with_bms_data(self) -> None: RANGE_TOPIC, DRIVETRAIN_RANGE_BMS, ) + self.assert_mqtt_topic( + SOC_TS_TOPIC, int(datetime(2025, 1, 1, 12, 0, 0).timestamp()) + ) expected_topics = { SOC_TOPIC, + SOC_TS_TOPIC, RANGE_TOPIC, } assert expected_topics == set(self.publisher.map.keys()) + async def test_imported_energy_initial_threshold_set(self) -> None: + """Initial call should set the first threshold but not trigger a refresh.""" + imported_energy = 1000.0 # 1 kWh + + self.openwb_integration.real_total_battery_capacity = CAR_CAPACITY_KWH + self.openwb_integration.computed_refresh_by_imported_energy_wh = None + + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + imported_energy, CHARGE_POLLING_MIN_PERCENT, VIN + ) + + assert not should_refresh, "Initial call should not trigger refresh" + assert ( + self.openwb_integration.computed_refresh_by_imported_energy_wh is not None + ), "Initial threshold should be computed and stored" + + async def test_imported_energy_threshold_reached_triggers_refresh(self) -> None: + """When imported energy increases beyond threshold, refresh should trigger.""" + imported_energy_start = 1000.0 # 1 kWh + threshold_wh = imported_energy_start + ENERGY_FOR_MIN_PCT + + self.openwb_integration.real_total_battery_capacity = CAR_CAPACITY_KWH + self.openwb_integration.computed_refresh_by_imported_energy_wh = threshold_wh + + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + threshold_wh, CHARGE_POLLING_MIN_PERCENT, VIN + ) + + assert should_refresh, "Refresh should trigger when threshold is reached" + + async def test_imported_energy_no_refresh_before_threshold(self) -> None: + """Should not trigger refresh before threshold is reached.""" + imported_energy_wh = 1000.0 # 1 kWh + threshold_wh = imported_energy_wh + ENERGY_FOR_MIN_PCT + + self.openwb_integration.real_total_battery_capacity = CAR_CAPACITY_KWH + self.openwb_integration.computed_refresh_by_imported_energy_wh = threshold_wh + + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + imported_energy_wh + (ENERGY_FOR_MIN_PCT / 2), + CHARGE_POLLING_MIN_PERCENT, + VIN, + ) + + assert not should_refresh, ( + "Should not refresh before threshold is fully reached" + ) + + async def test_imported_energy_updates_next_threshold(self) -> None: + """After refresh, a new threshold should be calculated.""" + imported_energy_wh = 1000.0 # 1 kWh + + self.openwb_integration.real_total_battery_capacity = CAR_CAPACITY_KWH + self.openwb_integration.computed_refresh_by_imported_energy_wh = imported_energy_wh + + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + imported_energy_wh + ENERGY_FOR_MIN_PCT, + CHARGE_POLLING_MIN_PERCENT, + VIN, + ) + + assert should_refresh, "Refresh should be triggered at the threshold" + assert ( + self.openwb_integration.computed_refresh_by_imported_energy_wh + == imported_energy_wh + ENERGY_FOR_MIN_PCT * 2 + ), "Next threshold should be correctly updated" + + async def test_imported_energy_missing_battery_capacity(self) -> None: + """If no battery capacity is known, refresh calculation should be skipped.""" + self.openwb_integration.real_total_battery_capacity = None + + result = self.openwb_integration.should_refresh_by_imported_energy( + imported_energy_wh=1000.0, + charge_polling_min_percent=CHARGE_POLLING_MIN_PERCENT, + vin=VIN, + ) + + assert not result, "Should not refresh if capacity is unknown" + + async def test_imported_energy_charger_disconnected(self) -> None: + """If charger is disconnected, refresh calculation should be skipped.""" + self.openwb_integration.charger_connected = False + + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + imported_energy_wh=1000.0, + charge_polling_min_percent=CHARGE_POLLING_MIN_PERCENT, + vin=VIN, + ) + + assert not should_refresh, "Should not refresh if charger is disconnected" + + async def test_imported_energy_calculation_reset_refresh(self) -> None: + """Test that the refresh threshold is recalculated if imported_energy_wh decreases, simulating a daily reset or counter rollover.""" + imported_energy_wh = 1000.0 # 1 kWh + + self.openwb_integration.real_total_battery_capacity = CAR_CAPACITY_KWH + + # Initial energy value + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + imported_energy_wh, CHARGE_POLLING_MIN_PERCENT, VIN + ) + assert not should_refresh, "Initial call should not trigger refresh" + + # Increase energy above threshold + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + imported_energy_wh + ENERGY_FOR_MIN_PCT, + CHARGE_POLLING_MIN_PERCENT, + VIN, + ) + assert should_refresh, "Refresh should trigger when threshold is reached" + + # Simulate daily reset to 0 Wh + reset_energy_wh = 0.0 + # Should NOT trigger refresh immediately + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + reset_energy_wh, CHARGE_POLLING_MIN_PERCENT, VIN + ) + assert not should_refresh, "Reset should not trigger refresh" + + # Next increase above new threshold + should_refresh = self.openwb_integration.should_refresh_by_imported_energy( + reset_energy_wh + ENERGY_FOR_MIN_PCT, + CHARGE_POLLING_MIN_PERCENT, + VIN, + ) + assert should_refresh, "Refresh should trigger after reset and threshold reached" + + def assert_mqtt_topic(self, topic: str, value: Any) -> None: mqtt_map = self.publisher.map if topic in mqtt_map: