diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index d087ef0662..bfcf34fc25 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -58,7 +58,7 @@ class UpdateConfig: - DATASTORE_VERSION = 147 + DATASTORE_VERSION = 148 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -3847,3 +3847,20 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {topic: payload_component} self._loop_all_received_topics(upgrade) self._append_datastore_version(147) + + def upgrade_datastore_148(self) -> None: + """PSA-Modul entfernt: Die Schnittstelle wurde von PSA/Stellantis bereits 2024 abgeschaltet, + das Modul lieferte seitdem nur noch eine Fehlermeldung statt eines SoC-Werts.""" + def upgrade(topic: str, payload) -> Optional[dict]: + if re.search("^openWB/vehicle/[0-9]+/soc_module/config$", topic) is not None: + payload = decode_payload(payload) + if payload.get("type") == "psa": + pub_system_message( + {}, + "Das PSA-Fahrzeug-Modul wurde entfernt, da PSA/Stellantis die Schnittstelle bereits " + "2024 abgeschaltet hat. Bitte konfiguriere ein anderes Fahrzeug-Modul, z.B. Tronity.", + MessageType.WARNING, + ) + return {topic: NO_MODULE} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(148) diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index 81f2c5aa0c..6609c21d2b 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -158,12 +158,15 @@ class CarState: def __init__(self, soc: float, range: Optional[float] = None, soc_timestamp: Optional[float] = None, - odometer: Optional[float] = None): + odometer: Optional[float] = None, + warning: Optional[str] = None): """Args: soc: actual state of charge in percent range: actual range in km soc_timestamp: timestamp of last request as unix timestamp odometer: actual odometer of vehicle in km + warning: informational message about a vehicle-side setting affecting charging + (eg. a manufacturer battery-care-mode limit), shown to the user but not an error """ self.soc = soc self.range = range @@ -175,6 +178,7 @@ def __init__(self, soc: float, soc_timestamp /= 1000 self.soc_timestamp = soc_timestamp self.odometer = odometer + self.warning = warning @auto_str diff --git a/packages/modules/common/configurable_vehicle.py b/packages/modules/common/configurable_vehicle.py index d4bf8336f9..773b97889c 100644 --- a/packages/modules/common/configurable_vehicle.py +++ b/packages/modules/common/configurable_vehicle.py @@ -130,6 +130,9 @@ def _get_carstate_by_source(self, vehicle_update_data: VehicleUpdateData, source try: _carState = self.__component_updater(vehicle_update_data) if type(_carState) is CarState: + if _carState.warning: + self.fault_state.warning(_carState.warning) + self.fault_state.store_error() _odometer = _carState.odometer _now = int(time.time()) _diff = 0 diff --git a/packages/modules/vehicles/bmw_cardata/soc.py b/packages/modules/vehicles/bmw_cardata/soc.py index 5cbcb1a6d2..86c298427b 100644 --- a/packages/modules/vehicles/bmw_cardata/soc.py +++ b/packages/modules/vehicles/bmw_cardata/soc.py @@ -45,6 +45,7 @@ # wird nur bei Fahrtende befüllt ("Mileage after last drive") "vehicle.trip.segment.end.travelledDistance", ] +FIELD_TARGET_SOC = "vehicle.powertrain.electric.battery.stateOfCharge.target" CONTAINER_NAME = "ChargeStats" CONTAINER_PURPOSE = "openWB" @@ -53,13 +54,16 @@ *FIELD_SOC_CANDIDATES, *FIELD_RANGE_CANDIDATES, *FIELD_ODOMETER_CANDIDATES, + FIELD_TARGET_SOC, +] +# Manche neueren Fahrzeuge kennen das älteste (erste) SoC-Attribut oder das +# Lade-Ziel-Attribut nicht mehr. Legt man einen Container mit einem für das +# Fahrzeug nicht verfügbaren Descriptor an, antwortet die BMW-API dabei +# offenbar teils mit einem Serverfehler statt einer sauberen 400er. Als +# Fallback wird die Container-Erstellung ohne diese Attribute wiederholt. +CONTAINER_DESCRIPTORS_FALLBACK = [ + d for d in CONTAINER_DESCRIPTORS if d not in (FIELD_SOC_CANDIDATES[0], FIELD_TARGET_SOC) ] -# Manche neueren Fahrzeuge kennen das älteste (erste) SoC-Attribut nicht mehr. -# Legt man einen Container mit einem für das Fahrzeug nicht verfügbaren -# Descriptor an, antwortet die BMW-API dabei offenbar teils mit einem -# Serverfehler statt einer sauberen 400er. Als Fallback wird die -# Container-Erstellung ohne dieses eine Attribut wiederholt. -CONTAINER_DESCRIPTORS_FALLBACK = [d for d in CONTAINER_DESCRIPTORS if d != FIELD_SOC_CANDIDATES[0]] def _get_session(token: Optional[str] = None): @@ -144,7 +148,8 @@ def _create_container(token: str, descriptors: List[str] = None, _is_retry: bool if not _is_retry: log.warning( "BMW CarData: Container-Erstellung fehlgeschlagen (%s). Versuche erneut ohne " - "'%s' (evtl. für dieses Fahrzeug nicht verfügbar).", e, FIELD_SOC_CANDIDATES[0], + "'%s'/'%s' (evtl. für dieses Fahrzeug nicht verfügbar).", + e, FIELD_SOC_CANDIDATES[0], FIELD_TARGET_SOC, ) return _create_container(token, CONTAINER_DESCRIPTORS_FALLBACK, _is_retry=True) raise Exception(f"BMW CarData: Container konnte nicht erstellt werden: {e}") @@ -282,14 +287,20 @@ def fetch_soc(config: BmwCardataSetup, vehicle: int = 0) -> CarState: range_raw = _extract_first_value(td, FIELD_RANGE_CANDIDATES) status = _extract_value(td, FIELD_STATUS) odometer_raw = _extract_first_value(td, FIELD_ODOMETER_CANDIDATES) + target_soc_raw = _extract_value(td, FIELD_TARGET_SOC) soc = int(float(soc_raw)) if soc_raw is not None else None vehicle_range = int(float(range_raw)) if range_raw is not None else None odometer = int(float(odometer_raw)) if odometer_raw is not None else None + target_soc = int(float(target_soc_raw)) if target_soc_raw is not None else None if soc is None: raise Exception("BMW CarData: Kein SoC-Wert in API-Antwort gefunden!") + warning = None + if target_soc is not None and target_soc < 100: + warning = f"Das Fahrzeug begrenzt die Ladung selbst auf {target_soc}%." + if vehicle_range is None and cfg.container_id: log.warning( "BMW CarData: Kein Reichweitenwert im Container gefunden – " @@ -304,7 +315,7 @@ def fetch_soc(config: BmwCardataSetup, vehicle: int = 0) -> CarState: status, odometer, ) - return CarState(soc=soc, range=vehicle_range, odometer=odometer) + return CarState(soc=soc, range=vehicle_range, odometer=odometer, warning=warning) def create_vehicle(vehicle_config: BmwCardataSetup, vehicle: int): diff --git a/packages/modules/vehicles/bmw_cardata/soc_test.py b/packages/modules/vehicles/bmw_cardata/soc_test.py index ded2ed330f..c996dab045 100644 --- a/packages/modules/vehicles/bmw_cardata/soc_test.py +++ b/packages/modules/vehicles/bmw_cardata/soc_test.py @@ -6,7 +6,9 @@ from modules.common import store from modules.common.abstract_vehicle import VehicleUpdateData from modules.common.component_context import SingleComponentUpdateContext -from modules.vehicles.bmw_cardata.soc import FIELD_SOC_CANDIDATES, create_vehicle, fetch_soc +from modules.vehicles.bmw_cardata.soc import ( + FIELD_SOC_CANDIDATES, FIELD_TARGET_SOC, create_vehicle, fetch_soc, +) from modules.vehicles.bmw_cardata.config import BmwCardataSetup, BmwCardataConfiguration @@ -169,6 +171,92 @@ def test_odometer_trip_end_fallback(self, monkeypatch): result = fetch_soc(self._make_config()) assert result.odometer == 45000 + def test_target_soc_below_100_has_warning(self, monkeypatch): + mock_response = Mock() + mock_response.json.return_value = { + "telematicData": { + "vehicle.drivetrain.electricEngine.charging.level": {"value": "75", "unit": "%"}, + "vehicle.powertrain.electric.battery.stateOfCharge.target": {"value": "80", "unit": "%"}, + } + } + mock_session = Mock() + mock_session.get.return_value = mock_response + mock_session.headers = {} + monkeypatch.setattr("modules.vehicles.bmw_cardata.soc.req.get_http_session", Mock(return_value=mock_session)) + + result = fetch_soc(self._make_config()) + assert result.warning is not None + assert "80" in result.warning + + def test_target_soc_100_has_no_warning(self, monkeypatch): + mock_response = Mock() + mock_response.json.return_value = { + "telematicData": { + "vehicle.drivetrain.electricEngine.charging.level": {"value": "75", "unit": "%"}, + "vehicle.powertrain.electric.battery.stateOfCharge.target": {"value": "100", "unit": "%"}, + } + } + mock_session = Mock() + mock_session.get.return_value = mock_response + mock_session.headers = {} + monkeypatch.setattr("modules.vehicles.bmw_cardata.soc.req.get_http_session", Mock(return_value=mock_session)) + + result = fetch_soc(self._make_config()) + assert result.warning is None + + def test_no_target_soc_has_no_warning(self, monkeypatch): + mock_response = Mock() + mock_response.json.return_value = { + "telematicData": { + "vehicle.drivetrain.electricEngine.charging.level": {"value": "75", "unit": "%"}, + } + } + mock_session = Mock() + mock_session.get.return_value = mock_response + mock_session.headers = {} + monkeypatch.setattr("modules.vehicles.bmw_cardata.soc.req.get_http_session", Mock(return_value=mock_session)) + + result = fetch_soc(self._make_config()) + assert result.warning is None + + def test_container_create_retries_without_target_soc_descriptor(self, monkeypatch): + mock_get_response_empty = Mock() + mock_get_response_empty.json.return_value = {"containers": []} + + post_call_count = [0] + + def mock_post(url, json=None): + if "containers" in url: + post_call_count[0] += 1 + if post_call_count[0] == 1: + mock_resp = Mock() + mock_resp.status_code = 500 + raise RequestsHTTPError(response=mock_resp) + assert FIELD_TARGET_SOC not in json["technicalDescriptors"] + assert FIELD_SOC_CANDIDATES[0] not in json["technicalDescriptors"] + return Mock(json=Mock(return_value={"containerId": "fallback-container-id"})) + raise AssertionError(f"Unerwarteter POST an {url}") + + mock_session = Mock() + mock_session.get.return_value = mock_get_response_empty + mock_session.post.side_effect = mock_post + mock_session.headers = {} + monkeypatch.setattr("modules.vehicles.bmw_cardata.soc.req.get_http_session", Mock(return_value=mock_session)) + monkeypatch.setattr( + "modules.vehicles.bmw_cardata.soc._fetch_telematic_data", + Mock(return_value={ + "telematicData": { + "vehicle.powertrain.electric.battery.stateOfCharge.displayed": {"value": "58", "unit": "%"}, + } + }), + ) + + config = self._make_config(container_id="") + result = fetch_soc(config) + + assert result.soc == 58 + assert post_call_count[0] == 2 + def test_no_soc_raises(self, monkeypatch): mock_response = Mock() mock_response.json.return_value = {"telematicData": {}} diff --git a/packages/modules/vehicles/kia/api.py b/packages/modules/vehicles/kia/api.py index 3dd8247dfe..016fbe1d35 100644 --- a/packages/modules/vehicles/kia/api.py +++ b/packages/modules/vehicles/kia/api.py @@ -885,12 +885,21 @@ def getStatusFull(vehicle_id: str, control_token: str, log.warning("kia.getStatusFull: odometer not available in " "vehicle status response") + warning = None + try: + target_soc = int(response_dict['resMsg']['state']['Vehicle']['Green'] + ['ChargingInformation']['TargetSoC']['Standard']) + if target_soc < 100: + warning = f"Das Fahrzeug begrenzt die Ladung selbst auf {target_soc}% (AC-Ladeziel)." + except (KeyError, TypeError, ValueError): + log.debug("kia.getStatusFull: AC-Ladeziel not available in vehicle status response") + except Exception: log.exception("kia.getStatusFull: receiving update error: " + response) raise - return CarState(soc=soc, range=range, odometer=odometer) + return CarState(soc=soc, range=range, odometer=odometer, warning=warning) # ---------- main function ---------- diff --git a/packages/modules/vehicles/kia/api_test.py b/packages/modules/vehicles/kia/api_test.py new file mode 100644 index 0000000000..ac97c66e6d --- /dev/null +++ b/packages/modules/vehicles/kia/api_test.py @@ -0,0 +1,49 @@ +import json +from unittest.mock import Mock + +import pytest + +from modules.vehicles.kia import api + + +def _status_response(target_soc=None): + vehicle = { + "Green": { + "BatteryManagement": {"BatteryRemain": {"Ratio": 55}}, + }, + "Drivetrain": { + "FuelSystem": {"DTE": {"Total": 300}}, + "Odometer": 12345, + }, + } + if target_soc is not None: + vehicle["Green"]["ChargingInformation"] = {"TargetSoC": {"Standard": target_soc}} + return json.dumps({"resMsg": {"state": {"Vehicle": vehicle}}}) + + +class TestKiaApi: + @pytest.fixture(autouse=True) + def set_up(self, monkeypatch): + monkeypatch.setattr(api, "getString", Mock(return_value="x")) + monkeypatch.setattr(api, "getStamp", Mock(return_value="stamp")) + monkeypatch.setattr(api.time, "sleep", Mock()) + + def _call(self, monkeypatch, response_json): + monkeypatch.setattr(api, "getHTTP", Mock(return_value=response_json)) + token = {"deviceId": "d", "gcmClientId": "c", "gcmVehicleId": "v", + "tokenType": "Bearer", "accessToken": "a"} + return api.getStatusFull("vehicle-id", "control-token", token, "kia") + + def test_no_target_soc_has_no_warning(self, monkeypatch): + result = self._call(monkeypatch, _status_response()) + assert result.warning is None + assert result.soc == 55 + + def test_target_soc_below_100_has_warning(self, monkeypatch): + result = self._call(monkeypatch, _status_response(target_soc=80)) + assert result.warning is not None + assert "80" in result.warning + + def test_target_soc_100_has_no_warning(self, monkeypatch): + result = self._call(monkeypatch, _status_response(target_soc=100)) + assert result.warning is None diff --git a/packages/modules/vehicles/myskoda/api.py b/packages/modules/vehicles/myskoda/api.py index 9e46bfa8af..ca4856b0ab 100644 --- a/packages/modules/vehicles/myskoda/api.py +++ b/packages/modules/vehicles/myskoda/api.py @@ -118,3 +118,14 @@ def extract_odometer(data: dict) -> Optional[float]: if odometer is None or odometer.get("mileageInKm") is None: return None return float(odometer["mileageInKm"]) + + +def extract_warning(data: dict) -> Optional[str]: + vehicle = data.get("vehicle") or {} + settings = ((vehicle.get("charging") or {}).get("settings")) or {} + if settings.get("chargingCareMode") != "ACTIVATED": + return None + target = settings.get("targetStateOfChargeInPercent") + if target is None or target >= 100: + return None + return f"Battery Care Mode ist im Fahrzeug aktiv und begrenzt die Ladung selbst auf {target}%." diff --git a/packages/modules/vehicles/myskoda/api_test.py b/packages/modules/vehicles/myskoda/api_test.py new file mode 100644 index 0000000000..67d815c758 --- /dev/null +++ b/packages/modules/vehicles/myskoda/api_test.py @@ -0,0 +1,36 @@ +from modules.vehicles.myskoda.api import extract_warning + + +def _data(charging_care_mode=None, target_soc=None): + settings = {} + if charging_care_mode is not None: + settings["chargingCareMode"] = charging_care_mode + if target_soc is not None: + settings["targetStateOfChargeInPercent"] = target_soc + return {"vehicle": {"charging": {"settings": settings}}} + + +def test_extract_warning_without_settings_returns_none(): + assert extract_warning({"vehicle": {}}) is None + + +def test_extract_warning_care_mode_deactivated_returns_none(): + data = _data(charging_care_mode="DEACTIVATED", target_soc=80) + assert extract_warning(data) is None + + +def test_extract_warning_care_mode_activated_returns_warning(): + data = _data(charging_care_mode="ACTIVATED", target_soc=80) + warning = extract_warning(data) + assert warning is not None + assert "80" in warning + + +def test_extract_warning_care_mode_activated_without_target_returns_none(): + data = _data(charging_care_mode="ACTIVATED") + assert extract_warning(data) is None + + +def test_extract_warning_care_mode_activated_target_100_returns_none(): + data = _data(charging_care_mode="ACTIVATED", target_soc=100) + assert extract_warning(data) is None diff --git a/packages/modules/vehicles/myskoda/soc.py b/packages/modules/vehicles/myskoda/soc.py index 5e182dff83..c6c03509d4 100644 --- a/packages/modules/vehicles/myskoda/soc.py +++ b/packages/modules/vehicles/myskoda/soc.py @@ -27,8 +27,9 @@ def fetch(vehicle_config: Myskoda, vehicle: int) -> CarState: soc = api.extract_soc(data) range_km = api.extract_range(data) odometer_km = api.extract_odometer(data) + warning = api.extract_warning(data) - return CarState(soc=soc, range=range_km, odometer=odometer_km) + return CarState(soc=soc, range=range_km, odometer=odometer_km, warning=warning) def create_vehicle(vehicle_config: Myskoda, vehicle: int): diff --git a/packages/modules/vehicles/psa/__init__.py b/packages/modules/vehicles/psa/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/modules/vehicles/psa/api.py b/packages/modules/vehicles/psa/api.py deleted file mode 100644 index f0194ea04e..0000000000 --- a/packages/modules/vehicles/psa/api.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 - -import logging -from modules.common.component_state import CarState - -from modules.vehicles.psa.config import PSAConfiguration - -log = logging.getLogger(__name__) - - -def fetch_soc(config: PSAConfiguration, - vehicle_id: int) -> CarState: - raise Exception("Dieses Modul ist nicht mehr funktionstüchtig, da PSA die Schnittstelle entfernt hat." - " Bitte zu einem anderen Anbieter, z.B. Tronity, wechseln.") diff --git a/packages/modules/vehicles/psa/config.py b/packages/modules/vehicles/psa/config.py deleted file mode 100644 index 57b00cad96..0000000000 --- a/packages/modules/vehicles/psa/config.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Optional - - -class PSAConfiguration: - def __init__(self, - user_id: Optional[str] = None, - password: Optional[str] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - manufacturer: Optional[str] = None, - calculate_soc: bool = False, - vin: Optional[str] = None): - self.user_id = user_id - self.password = password - self.client_id = client_id - self.client_secret = client_secret - self.manufacturer = manufacturer - self.calculate_soc = calculate_soc - self.vin = vin - - -class PSA: - def __init__(self, - name: str = "PSA (nicht mehr verwenden)", - type: str = "psa", - official: bool = False, - configuration: PSAConfiguration = None) -> None: - self.name = name - self.type = type - self.official = official - self.configuration = configuration or PSAConfiguration() diff --git a/packages/modules/vehicles/psa/soc.py b/packages/modules/vehicles/psa/soc.py deleted file mode 100644 index 5789e8b72e..0000000000 --- a/packages/modules/vehicles/psa/soc.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import List - -import logging - -from helpermodules.cli import run_using_positional_cli_args -from modules.common import store -from modules.common.abstract_device import DeviceDescriptor -from modules.common.abstract_vehicle import VehicleUpdateData -from modules.common.component_state import CarState -from modules.common.configurable_vehicle import ConfigurableVehicle -from modules.vehicles.psa import api -from modules.vehicles.psa.config import PSA, PSAConfiguration - - -log = logging.getLogger(__name__) - - -def create_vehicle(vehicle_config: PSA, vehicle: int): - def updater(vehicle_update_data: VehicleUpdateData) -> CarState: - return api.fetch_soc(vehicle_config.configuration, vehicle) - return ConfigurableVehicle(vehicle_config=vehicle_config, - component_updater=updater, - vehicle=vehicle, - calc_while_charging=True) - - -def psa_update(user_id: str, - password: str, - client_id: str, - client_secret: str, - manufacturer: str, - calculate_soc: bool, - vin: str, - charge_point: int): - log.debug("psa-update: user_id=%s, VIN=%s, chargepoint=%s", user_id, vin, charge_point) - store.get_car_value_store(charge_point).store.set(api.fetch_soc(PSAConfiguration( - user_id=user_id, - password=password, - client_id=client_id, - client_secret=client_secret, - manufacturer=manufacturer, - calculate_soc=calculate_soc, - vin=vin), charge_point)) - - -def main(argv: List[str]): - run_using_positional_cli_args(psa_update, argv) - - -device_descriptor = DeviceDescriptor(configuration_factory=PSA) diff --git a/packages/modules/vehicles/tesla/api.py b/packages/modules/vehicles/tesla/api.py index 9efe56a112..71efbc90b9 100644 --- a/packages/modules/vehicles/tesla/api.py +++ b/packages/modules/vehicles/tesla/api.py @@ -4,7 +4,7 @@ import logging import time import json -from typing import Tuple +from typing import Optional, Tuple from requests.adapters import HTTPAdapter from urllib3 import PoolManager @@ -96,17 +96,26 @@ def post_wake_up_command(vehicle: int, token: TeslaSocToken) -> str: return response["response"]["state"] -def request_data(vehicle: int, token: TeslaSocToken) -> Tuple[float, int, float, int]: +def __charge_limit_warning(charge_state: dict) -> Optional[str]: + limit = charge_state.get("charge_limit_soc") + if limit is None or limit >= 100: + return None + return f"Das Fahrzeug begrenzt die Ladung selbst auf {int(limit)}%." + + +def request_data(vehicle: int, token: TeslaSocToken) -> Tuple[float, int, float, int, Optional[str]]: vehicle_id = __get_vehicle_id(vehicle, token) data_part = f"vehicles/{vehicle_id}/vehicle_data" response = __request_data(data_part, token) response = json.loads(response) - soc = float(response["response"]["charge_state"]["battery_level"]) + charge_state = response["response"]["charge_state"] + soc = float(charge_state["battery_level"]) # convert miles to km - range = int(float(response["response"]["charge_state"]["battery_range"]) * 1.60934) + range = int(float(charge_state["battery_range"]) * 1.60934) odometer = int(float(response["response"]["vehicle_state"]["odometer"]) * 1.60934) - soc_timestamp = float(response["response"]["charge_state"]["timestamp"]) / 1000 - return soc, range, soc_timestamp, odometer + soc_timestamp = float(charge_state["timestamp"]) / 1000 + warning = __charge_limit_warning(charge_state) + return soc, range, soc_timestamp, odometer, warning def validate_token(token: TeslaSocToken) -> TeslaSocToken: diff --git a/packages/modules/vehicles/tesla/api_test.py b/packages/modules/vehicles/tesla/api_test.py new file mode 100644 index 0000000000..3273ae17d0 --- /dev/null +++ b/packages/modules/vehicles/tesla/api_test.py @@ -0,0 +1,51 @@ +import json + +import pytest + +from modules.vehicles.tesla import api +from modules.vehicles.tesla.config import TeslaSocToken + + +def _response(charge_limit_soc=None): + charge_state = { + "battery_level": "55", + "battery_range": "200", + "timestamp": "1652683252000", + } + if charge_limit_soc is not None: + charge_state["charge_limit_soc"] = charge_limit_soc + return json.dumps({ + "response": { + "charge_state": charge_state, + "vehicle_state": {"odometer": "1000"}, + } + }) + + +class TestTeslaApi: + @pytest.fixture(autouse=True) + def set_up(self, monkeypatch): + monkeypatch.setattr(api, "__get_vehicle_id", lambda vehicle, token: "vehicle-id") + + def test_request_data_without_charge_limit_has_no_warning(self, monkeypatch): + monkeypatch.setattr(api, "__request_data", lambda data_part, token: _response()) + + soc, range, soc_timestamp, odometer, warning = api.request_data(0, TeslaSocToken()) + + assert soc == 55.0 + assert warning is None + + def test_request_data_with_charge_limit_below_100_has_warning(self, monkeypatch): + monkeypatch.setattr(api, "__request_data", lambda data_part, token: _response(charge_limit_soc=80)) + + *_, warning = api.request_data(0, TeslaSocToken()) + + assert warning is not None + assert "80" in warning + + def test_request_data_with_charge_limit_100_has_no_warning(self, monkeypatch): + monkeypatch.setattr(api, "__request_data", lambda data_part, token: _response(charge_limit_soc=100)) + + *_, warning = api.request_data(0, TeslaSocToken()) + + assert warning is None diff --git a/packages/modules/vehicles/tesla/soc.py b/packages/modules/vehicles/tesla/soc.py index 8c1855b3d4..bd1aa66876 100644 --- a/packages/modules/vehicles/tesla/soc.py +++ b/packages/modules/vehicles/tesla/soc.py @@ -22,9 +22,9 @@ def fetch(vehicle_config: TeslaSoc, vehicle_update_data: VehicleUpdateData) -> C f"Fehler beim Aufwecken des Fahrzeugs: {e}\n" "Der abgerufene SoC-Wert ist möglicherweise veraltet." ) - soc, range, soc_timestamp, odometer = api.request_data( + soc, range, soc_timestamp, odometer, warning = api.request_data( vehicle=vehicle_config.configuration.tesla_ev_num, token=vehicle_config.configuration.token) - return CarState(soc=soc, range=range, soc_timestamp=soc_timestamp, odometer=odometer) + return CarState(soc=soc, range=range, soc_timestamp=soc_timestamp, odometer=odometer, warning=warning) def _wake_up_car(vehicle_config: TeslaSoc): diff --git a/packages/modules/vehicles/tesla/soc_test.py b/packages/modules/vehicles/tesla/soc_test.py index 5d540e34aa..da0974dded 100644 --- a/packages/modules/vehicles/tesla/soc_test.py +++ b/packages/modules/vehicles/tesla/soc_test.py @@ -17,7 +17,7 @@ def set_up(self, monkeypatch): self.mock_context_exit = Mock(return_value=True) self.mock_validate_token = Mock(name="validate_token", return_value=self.token) self.mock_post_wake_up_command = Mock(name="post_wake_up_command", return_value="online") - self.mock_request_data = Mock(name="request_data", return_value=(42.5, 438.2, 1652683252, 12345)) + self.mock_request_data = Mock(name="request_data", return_value=(42.5, 438.2, 1652683252, 12345, None)) self.mock_value_store = Mock(name="value_store") monkeypatch.setattr(api, "validate_token", self.mock_validate_token) monkeypatch.setattr(api, "post_wake_up_command", self.mock_post_wake_up_command) @@ -54,6 +54,17 @@ def test_update_updates_value_store_not_charging(self, monkeypatch): assert self.mock_value_store.set.call_args[0][0].soc_timestamp == 1652683252 assert self.mock_value_store.set.call_args[0][0].odometer == 12345 + def test_update_updates_value_store_with_charge_limit_warning(self, monkeypatch): + # setup + self.mock_request_data.return_value = (42.5, 438.2, 1652683252, 12345, "Ladelimit 80%") + + # execution + create_vehicle(TeslaSoc(configuration=TeslaSocConfiguration( + tesla_ev_num=0, token=self.token)), 0).update(VehicleUpdateData()) + + # evaluation + assert self.mock_value_store.set.call_args[0][0].warning == "Ladelimit 80%" + def test_update_passes_errors_to_context(self, monkeypatch): # setup dummy_error = Exception("Der SoC kann nicht ausgelesen werden") diff --git a/packages/modules/vehicles/vweuda/libeuda.py b/packages/modules/vehicles/vweuda/libeuda.py index a2da096921..caba609a1a 100755 --- a/packages/modules/vehicles/vweuda/libeuda.py +++ b/packages/modules/vehicles/vweuda/libeuda.py @@ -631,6 +631,14 @@ def _created_on(entry: dict) -> datetime | None: return _filename_timestamp(entry.get("name", "")) +def get_field_value_by_fieldname(D: dict, field: str) -> str: + ret = None + for f in D: + if f['dataFieldName'] == field: + ret = f['value'] + return ret + + def get_field_value_by_key(D: dict, key: str, field: str) -> str: ret = None for f in D: @@ -725,12 +733,19 @@ def parse_vehicle_data(payload: dict) -> dict: else: _LOGGER.warning("soc_timestamp not found!") + warning = None + if get_field_value_by_fieldname(data, 'setting.bcam_activation') == 'BCAM_ACTIVATION_ACTIVATED': + bcam_threshold = get_field_value_by_fieldname(data, 'battery_care_mode.charge_bcam_threshold') + if bcam_threshold: + warning = f"Battery Care Mode ist im Fahrzeug aktiv und begrenzt die Ladung selbst auf {bcam_threshold}%." + return { 'soc': soc, 'range': range, 'soc_timestamp': soc_timestamp, 'soc_timestamp_str': soc_timestamp_str, 'odometer': odometer, + 'warning': warning, } @@ -962,7 +977,7 @@ def check_tests(self): async def get_status(self, conf: VWEUDA, vehicle: int, - vehicle_update_data: VehicleUpdateData) -> Union[int, float, str, float, float]: + vehicle_update_data: VehicleUpdateData) -> Union[int, float, str, float, float, str]: # error codes SOCERR-xx raised: # SOCERR-00: general error @@ -1022,12 +1037,14 @@ async def get_status(self, _LOGGER.info(f"wait for first EUDA result for VIN {ano_vin(self.vin)}") time.sleep(1) + warning = None if self.vin in euda.result: _LOGGER.debug(f"vehicle match: {ano_vin(self.vin)}") _ano_j = {} for vin in euda.result: _ano_j[ano_vin(vin)] = euda.result[vin] _LOGGER.info(f"result from thread:\n{json.dumps(_ano_j, indent=4)}") + warning = euda.result[self.vin].get('warning') soc = euda.result[self.vin]['soc'] range = euda.result[self.vin]['range'] try: @@ -1091,7 +1108,7 @@ async def get_status(self, # _LOGGER.info(f"get_status: publish soc_timestamp as 0: topic: {topic}, message: {ep0}") # Pub().pub(topic, ep0) - return float(soc), float(range), float(ts), ts_str, float(odometer) + return float(soc), float(range), float(ts), ts_str, float(odometer), warning except Exception as e: _LOGGER.exception(f"get_status failed 0, exception={e}") # if exception is a SOCERR reraise it, otherwise raise general SOCERR-00 @@ -1106,7 +1123,7 @@ async def get_status(self, # sync function def fetch_soc(conf: VWEUDA, vehicle: int, - vehicle_update_data: VehicleUpdateData) -> Union[float, float, float, str, float]: + vehicle_update_data: VehicleUpdateData) -> Union[float, float, float, str, float, str]: # prepare and call async method loop = new_event_loop() @@ -1114,7 +1131,7 @@ def fetch_soc(conf: VWEUDA, # get soc, range from server a = euda() - soc, range, soc_ts, soc_tsX, odometer =\ + soc, range, soc_ts, soc_tsX, odometer, warning =\ loop.run_until_complete(a.get_status(conf, vehicle, vehicle_update_data)) - return soc, range, soc_ts, soc_tsX, odometer + return soc, range, soc_ts, soc_tsX, odometer, warning diff --git a/packages/modules/vehicles/vweuda/libeuda_test.py b/packages/modules/vehicles/vweuda/libeuda_test.py new file mode 100644 index 0000000000..ec39aa2c00 --- /dev/null +++ b/packages/modules/vehicles/vweuda/libeuda_test.py @@ -0,0 +1,48 @@ +from modules.vehicles.vweuda.libeuda import parse_vehicle_data + +BASE_DATA = [ + {"key": "k-ts", "dataFieldName": "car_captured_time", "value": "2026-09-15T09:00:00Z"}, + {"key": "ac1108b1-b8cc-3db9-a663-03d387e42223", "dataFieldName": "battery_level_HV.value", "value": "55.0"}, +] + + +def test_parse_vehicle_data_without_bcam_fields_returns_no_warning(): + result = parse_vehicle_data({"Data": BASE_DATA}) + + assert result["warning"] is None + assert result["soc"] == "55.0" + + +def test_parse_vehicle_data_with_bcam_inactive_returns_no_warning(): + data = BASE_DATA + [ + {"key": "k-bcam", "dataFieldName": "setting.bcam_activation", "value": "BCAM_ACTIVATION_DEACTIVATED"}, + {"key": "k-thr", "dataFieldName": "battery_care_mode.charge_bcam_threshold", "value": "80"}, + ] + + result = parse_vehicle_data({"Data": data}) + + assert result["warning"] is None + assert result["soc"] == "55.0" + + +def test_parse_vehicle_data_with_bcam_active_returns_warning(): + data = BASE_DATA + [ + {"key": "k-bcam", "dataFieldName": "setting.bcam_activation", "value": "BCAM_ACTIVATION_ACTIVATED"}, + {"key": "k-thr", "dataFieldName": "battery_care_mode.charge_bcam_threshold", "value": "80"}, + ] + + result = parse_vehicle_data({"Data": data}) + + assert result["warning"] is not None + assert "80" in result["warning"] + assert result["soc"] == "55.0" + + +def test_parse_vehicle_data_with_bcam_active_but_no_threshold_returns_no_warning(): + data = BASE_DATA + [ + {"key": "k-bcam", "dataFieldName": "setting.bcam_activation", "value": "BCAM_ACTIVATION_ACTIVATED"}, + ] + + result = parse_vehicle_data({"Data": data}) + + assert result["warning"] is None diff --git a/packages/modules/vehicles/vweuda/soc.py b/packages/modules/vehicles/vweuda/soc.py index c463c1bd16..fc4d905abc 100755 --- a/packages/modules/vehicles/vweuda/soc.py +++ b/packages/modules/vehicles/vweuda/soc.py @@ -15,9 +15,9 @@ def fetch(vehicle_update_data: VehicleUpdateData, config: VWEUDA, vehicle: int) -> CarState: - soc, range, soc_ts, soc_tsX, odometer = libeuda.fetch_soc(config, vehicle, vehicle_update_data) + soc, range, soc_ts, soc_tsX, odometer, warning = libeuda.fetch_soc(config, vehicle, vehicle_update_data) log.debug(f"soc return: soc={soc}, range={range}, soc_ts={soc_ts}, soc_tsX={soc_tsX}, odometer={odometer}") - return CarState(soc=soc, range=range, soc_timestamp=soc_ts, odometer=odometer) + return CarState(soc=soc, range=range, soc_timestamp=soc_ts, odometer=odometer, warning=warning) def create_vehicle(vehicle_config: VWEUDA, vehicle: int):