From 1a8885ae010b067b91d2daa9dbab3b23457c0625 Mon Sep 17 00:00:00 2001 From: Michael Wikberg Date: Thu, 24 Feb 2022 01:16:39 +0200 Subject: [PATCH] Working service for minimum charge limit and clima temp. Switch for departure schedules 1..3 on/off. --- tests/integration_test.py | 2 +- tests/vw_utilities_test.py | 37 +++- volkswagencarnet/vw_connection.py | 46 +++-- volkswagencarnet/vw_dashboard.py | 38 +++- volkswagencarnet/vw_timer.py | 34 +++- volkswagencarnet/vw_utilities.py | 22 +++ volkswagencarnet/vw_vehicle.py | 279 +++++++++++++----------------- 7 files changed, 269 insertions(+), 189 deletions(-) diff --git a/tests/integration_test.py b/tests/integration_test.py index 978158aa..62e08cf5 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -41,7 +41,7 @@ async def test_set_timer(): connection = vw_connection.Connection(session, username, password) await connection.doLogin() data = TimerData({}, {}) - await connection.setSchedule(vin=vin, data=data) + await connection.setTimersAndProfiles(vin=vin, data=data) assert 1 == 1 diff --git a/tests/vw_utilities_test.py b/tests/vw_utilities_test.py index b1326c9d..0e4ac78e 100644 --- a/tests/vw_utilities_test.py +++ b/tests/vw_utilities_test.py @@ -4,7 +4,18 @@ from unittest import TestCase, mock from unittest.mock import DEFAULT -from volkswagencarnet.vw_utilities import camel2slug, is_valid_path, obj_parser, json_loads, read_config, make_url +from volkswagencarnet.vw_utilities import ( + camel2slug, + is_valid_path, + obj_parser, + json_loads, + read_config, + make_url, + fahrenheit_to_vw, + vw_to_celsius, + vw_to_fahrenheit, + celsius_to_vw, +) class UtilitiesTest(TestCase): @@ -130,3 +141,27 @@ def test_make_url(self): """Test placeholder replacements.""" self.assertEqual("foo/2/baz", make_url("foo/{bar}/baz{baz}", bar=2, baz="")) self.assertEqual("foo/asd/2", make_url("foo/{baz}/$bar", bar=2, baz="asd")) + + def test_celcius_to_vw(self): + """Test Celsius conversion.""" + self.assertEqual(2730, celsius_to_vw(0)) + self.assertEqual(2955, celsius_to_vw(22.4)) + self.assertEqual(2960, celsius_to_vw(22.7)) + + def test_fahrenheit_to_vw(self): + """Test Fahrenheit conversion.""" + self.assertEqual(2730, fahrenheit_to_vw(32)) + self.assertEqual(2955, fahrenheit_to_vw(72.3)) + self.assertEqual(2960, fahrenheit_to_vw(72.9)) + + def test_vw_to_celcius(self): + """Test Celsius conversion.""" + self.assertEqual(0, vw_to_celsius(2730)) + self.assertEqual(22.5, vw_to_celsius(2955)) + self.assertEqual(23, vw_to_celsius(2960)) + + def test_vw_to_fahrenheit(self): + """Test Fahrenheit conversion.""" + self.assertEqual(32, vw_to_fahrenheit(2730)) + self.assertEqual(72, vw_to_fahrenheit(2955)) + self.assertEqual(73, vw_to_fahrenheit(2960)) diff --git a/volkswagencarnet/vw_connection.py b/volkswagencarnet/vw_connection.py index b5459826..a0001598 100644 --- a/volkswagencarnet/vw_connection.py +++ b/volkswagencarnet/vw_connection.py @@ -1,32 +1,27 @@ #!/usr/bin/env python3 """Communicate with We Connect services.""" +import asyncio +import hashlib +import logging import re import secrets import sys import time -import logging -import asyncio -import hashlib +from base64 import b64encode, urlsafe_b64encode +from datetime import timedelta, datetime +from json import dumps as to_json from random import random -from typing import Optional - -import jwt - from sys import version_info -from datetime import timedelta, datetime +from typing import Optional from urllib.parse import urljoin, parse_qs, urlparse -from json import dumps as to_json -from bs4 import BeautifulSoup -from base64 import b64encode, urlsafe_b64encode +import jwt from aiohttp import ClientSession, ClientTimeout, client_exceptions from aiohttp.hdrs import METH_GET, METH_POST -from volkswagencarnet.vw_timer import TimerData +from bs4 import BeautifulSoup from volkswagencarnet.vw_exceptions import AuthenticationException -from .vw_utilities import json_loads, read_config -from .vw_vehicle import Vehicle - +from volkswagencarnet.vw_timer import TimerData, TimersAndProfiles from .vw_const import ( BRAND, COUNTRY, @@ -41,6 +36,8 @@ USER_AGENT, APP_URI, ) +from .vw_utilities import json_loads, read_config +from .vw_vehicle import Vehicle version_info >= (3, 0) or exit("Python 3 required") @@ -50,6 +47,7 @@ JWT_ALGORITHMS = ["RS256"] +# noinspection PyPep8Naming class Connection: """Connection to VW-Group Connect services.""" @@ -1068,7 +1066,19 @@ async def setPreHeater(self, vin, data, spin): self._session_headers["Content-Type"] = content_type raise - async def setSchedule(self, vin, data: TimerData): + async def setTimersAndProfiles(self, vin, data: TimersAndProfiles): + """Set schedules.""" + return await self._setDepartureTimer(vin, data, "setTimersAndProfiles") + + async def setChargeMinLevel(self, vin, limit: int): + """Set schedules.""" + data: Optional[TimerData] = await self.getTimers(vin) + if data is None: + raise Exception("No existing timer data?") + data.timersAndProfiles.timerBasicSetting.set_charge_min_limit(limit) + return await self._setDepartureTimer(vin, data.timersAndProfiles, "setChargeMinLimit") + + async def _setDepartureTimer(self, vin, data: TimersAndProfiles, action: str): """Set schedules.""" try: await self.set_token("vwg") @@ -1077,8 +1087,8 @@ async def setSchedule(self, vin, data: TimerData): vin=vin, json={ "action": { - "timersAndProfiles": data.timersAndProfiles.json_updated["timer"], - "type": "setTimersAndProfiles", + "timersAndProfiles": data.json_updated["timer"], + "type": action, } }, ) diff --git a/volkswagencarnet/vw_dashboard.py b/volkswagencarnet/vw_dashboard.py index 301e3e05..845eca07 100644 --- a/volkswagencarnet/vw_dashboard.py +++ b/volkswagencarnet/vw_dashboard.py @@ -1,11 +1,10 @@ -# Utilities for integration with Home Assistant +"""Utilities for integration with Home Assistant.""" # Thanks to molobrakos import logging from typing import Union from volkswagencarnet.vw_timer import Timer, TimerData - from .vw_utilities import camel2slug CLIMA_DEFAULT_DURATION = 30 @@ -14,6 +13,8 @@ class Instrument: + """Base class for all components.""" + def __init__(self, component, attr, name, icon=None): self.attr = attr self.component = component @@ -510,22 +511,27 @@ def attributes(self): class DepartureTimer(Switch): + """Departure timers.""" + def __init__(self, id: Union[str, int]): self._id = id super().__init__(attr=f"departure_timer{id}", name=f"Departure Schedule {id}", icon="mdi:car-clock") @property def state(self): + """Return switch state.""" s: Timer = self.vehicle.schedule(self._id) return 1 if s.enabled else 0 async def turn_on(self): + """Enable schedule.""" schedule: TimerData = self.vehicle.attrs["timer"] schedule.get_schedule(self._id).enable() await self.vehicle.set_schedule(schedule) await self.vehicle.update() async def turn_off(self): + """Disable schedule.""" schedule: TimerData = self.vehicle.attrs["timer"] schedule.get_schedule(self._id).disable() await self.vehicle.set_schedule(schedule) @@ -533,10 +539,12 @@ async def turn_off(self): @property def assumed_state(self): + """Don't assume state info.""" return False @property def attributes(self): + """Schedule attributes.""" s: Timer = self.vehicle.schedule(self._id) return dict( # last_result="FIXME", @@ -680,6 +688,31 @@ def attributes(self): return dict(self.vehicle.request_results) +class ChargeMinLevel(Sensor): + """Get minimum charge level.""" + + def __init__(self): + """Init.""" + super().__init__( + attr="schedule_min_charge_level", + name="Minimum charge level for departure timers", + icon="mdi:battery-arrow-up", + unit="%", + ) + + @property + def state(self) -> Union[int, str]: + """Return the desired minimum charge level.""" + if self.vehicle.is_timer_basic_settings_supported: + return self.vehicle.timer_basic_settings.chargeMinLimit + return "Unknown" + + @property + def assumed_state(self): + """Don't assume anything about state.""" + return False + + def create_instruments(): return [ Position(), @@ -695,6 +728,7 @@ def create_instruments(): # ElectricClimatisationClimate(), # CombustionClimatisationClimate(), Charging(), + ChargeMinLevel(), DepartureTimer(1), DepartureTimer(2), DepartureTimer(3), diff --git a/volkswagencarnet/vw_timer.py b/volkswagencarnet/vw_timer.py index c59ee0f0..e957f5ba 100644 --- a/volkswagencarnet/vw_timer.py +++ b/volkswagencarnet/vw_timer.py @@ -4,6 +4,8 @@ from datetime import datetime from typing import Union, List, Optional, Dict +from volkswagencarnet.vw_utilities import celsius_to_vw, fahrenheit_to_vw, vw_to_celsius + _LOGGER = logging.getLogger(__name__) @@ -68,13 +70,27 @@ class BasicSettings(DepartureTimerClass): def __init__(self, timestamp: str, chargeMinLimit: Union[str, int], targetTemperature: Union[str, int]): """Init.""" self.timestamp = timestamp - self.chargeMinLimit = int(chargeMinLimit) - self.targetTemperature = int(targetTemperature) + self.chargeMinLimit: int = int(chargeMinLimit) + self.targetTemperature: int = int(targetTemperature) + + @property + def target_temperature_celsius(self): + """Get target temperature in Celsius.""" + return vw_to_celsius(self.targetTemperature) - def set_target_temperature(self, temp: int): + def set_target_temperature_celsius(self, temp: float): """Set target temperature for departure timers with climatisation enabled.""" - self.targetTemperature = temp - self._changed = True + new_temp = celsius_to_vw(temp) + if new_temp != self.targetTemperature: + self.targetTemperature = new_temp + self._changed = True + + def set_target_temperature_fahrenheit(self, temp: float): + """Set target temperature for departure timers with climatisation enabled.""" + new_temp = fahrenheit_to_vw(temp) + if new_temp != self.targetTemperature: + self.targetTemperature = new_temp + self._changed = True def set_charge_min_limit(self, limit: int): """Set the global minimum charge limit.""" @@ -183,7 +199,7 @@ def __init__(self, timerProfile: List[Union[dict, TimerProfile]]): # noinspection PyPep8Naming -class TimerAndProfiles(DepartureTimerClass): +class TimersAndProfiles(DepartureTimerClass): """Timer and profile object.""" def __init__( @@ -206,13 +222,13 @@ def __init__( class TimerData(DepartureTimerClass): """Top level timer object.""" - def __init__(self, timersAndProfiles: Union[Dict, TimerAndProfiles], status: Optional[dict]): + def __init__(self, timersAndProfiles: Union[Dict, TimersAndProfiles], status: Optional[dict]): """Init.""" try: self.timersAndProfiles = ( timersAndProfiles - if isinstance(timersAndProfiles, TimerAndProfiles) - else TimerAndProfiles(**timersAndProfiles) + if isinstance(timersAndProfiles, TimersAndProfiles) + else TimersAndProfiles(**timersAndProfiles) ) self.status = status self._valid = True diff --git a/volkswagencarnet/vw_utilities.py b/volkswagencarnet/vw_utilities.py index e4a99f64..77298745 100644 --- a/volkswagencarnet/vw_utilities.py +++ b/volkswagencarnet/vw_utilities.py @@ -145,3 +145,25 @@ def make_url(url: str, **kwargs): if "{" in url or "}" in url: raise ValueError("Not all values were substituted") return url + + +# TODO: is VW using 273.15 or 273? :) +def celsius_to_vw(val: float) -> int: + """Convert Celsius to VW format.""" + return int(5 * round(2 * (273.15 + val))) + + +def fahrenheit_to_vw(val: float) -> int: + """Convert Fahrenheit to VW format.""" + return int(5 * round(2 * (273.15 + (val - 32) * 5 / 9))) + + +def vw_to_celsius(val: int) -> float: + """Convert Celsius to VW format.""" + return round(2 * ((val / 10) - 273.15)) / 2 + + +# TODO: are F ints of floats? +def vw_to_fahrenheit(val: int) -> int: + """Convert Fahrenheit to VW format.""" + return int(round((val / 10 - 273.15) * 9 / 5 + 32)) diff --git a/volkswagencarnet/vw_vehicle.py b/volkswagencarnet/vw_vehicle.py index c8e729e1..aac0659e 100644 --- a/volkswagencarnet/vw_vehicle.py +++ b/volkswagencarnet/vw_vehicle.py @@ -7,7 +7,7 @@ from json import dumps as to_json from typing import Optional, Union, Any, Dict -from volkswagencarnet.vw_timer import TimerData, Timer +from volkswagencarnet.vw_timer import TimerData, Timer, BasicSettings from .vw_utilities import find_path, is_valid_path @@ -73,6 +73,43 @@ def __init__(self, conn, url): # vehicles_v1_cai, services_v1, vehicletelemetry_v1 } + def _in_progress(self, topic: str, unknown_offset: int = 0) -> bool: + """Check if request is already in progress.""" + if self._requests[topic].get("id", False): + timestamp = self._requests.get(topic, {}).get( + "timestamp", datetime.now() - timedelta(minutes=unknown_offset) + ) + if timestamp + timedelta(minutes=3) > datetime.now(): + self._requests.get(topic, {}).pop("id") + else: + _LOGGER.info(f"Action ({topic}) already in progress") + return True + return False + + async def _handle_response(self, response, topic: str, error_msg: Optional[str] = None): + """Handle errors in response and get requests remaining.""" + if not response: + self._requests[topic] = {"status": "Failed"} + _LOGGER.error(error_msg if error_msg is not None else "Failed to perform {topic} action") + raise Exception(error_msg if error_msg is not None else "Failed to perform {topic} action") + else: + remaining = response.get("rate_limit_remaining", -1) + if remaining >= 0: + _LOGGER.info(f"{remaining} requests") + self._requests["remaining"] = remaining + self._requests[topic] = { + "timestamp": datetime.now(), + "status": response.get("state", "Unknown"), + "id": response.get("id", 0), + } + if response.get("state", None) == "Throttled": + status = "Throttled" + _LOGGER.warning(f"Request throttled ({topic}") + else: + status = await self.wait_for_request(topic, response.get("id", 0)) + self._requests[topic] = {"status": status} + return True + # API get and set functions # # Init and update vehicle data async def discover(self): @@ -141,7 +178,7 @@ async def update(self): # Data collection functions async def get_realcardata(self): - """Fetch realcar data.""" + """Fetch realcardata.""" data = await self._connection.getRealCarData(self.vin) if data: self._states.update(data) @@ -275,19 +312,35 @@ async def set_charger_current(self, value): _LOGGER.error("No charger support.") raise Exception("No charger support.") + async def set_charge_min_level(self, level: int): + """Set the desired minimum charge level for departure schedules.""" + if self.is_schedule_min_charge_level_supported: + if (0 <= level <= 100) and level % 10 == 0: + if self._in_progress("departuretimer"): + return False + try: + self._requests["latest"] = "Departuretimer" + response = await self._connection.setChargeMinLevel(self.vin, level) + return await self._handle_response( + response=response, topic="departuretimer", error_msg="Failed to set minimum charge level" + ) + except Exception as error: + _LOGGER.warning(f"Failed to set minimum charge level - {error}") + self._requests["departuretimer"] = {"status": "Exception"} + raise Exception(f"Failed to set minimum charge level - {error}") + else: + raise Exception("Level must be 0, 10, ..., 100") + else: + _LOGGER.error("Cannot set minimum level") + raise Exception("Cannot set minimum level") + async def set_charger(self, action): """Charging actions.""" if not self._services.get("rbatterycharge_v1", False): _LOGGER.info("Remote start/stop of charger is not supported.") raise Exception("Remote start/stop of charger is not supported.") - if self._requests["batterycharge"].get("id", False): - timestamp = self._requests.get("batterycharge", {}).get("timestamp", datetime.now()) - expired = datetime.now() - timedelta(minutes=3) - if expired > timestamp: - self._requests.get("batterycharge", {}).pop("id") - else: - _LOGGER.debug("Charging action already in progress") - return False + if self._in_progress("batterycharge"): + return False if action in ["start", "stop"]: data = {"action": {"type": action}} elif action.get("action", {}).get("type", "") == "setSettings": @@ -298,23 +351,9 @@ async def set_charger(self, action): try: self._requests["latest"] = "Charger" response = await self._connection.setCharger(self.vin, data) - if not response: - self._requests["batterycharge"] = {"status": "Failed"} - _LOGGER.error(f"Failed to {action} charging") - raise Exception(f"Failed to {action} charging") - else: - self._requests["remaining"] = response.get("rate_limit_remaining", -1) - self._requests["batterycharge"] = { - "timestamp": datetime.now(), - "status": response.get("state", "Unknown"), - "id": response.get("id", 0), - } - if response.get("state", None) == "Throttled": - status = "Throttled" - else: - status = await self.wait_for_request("batterycharge", response.get("id", 0)) - self._requests["batterycharge"] = {"status": status} - return True + return await self._handle_response( + response=response, topic="batterycharge", error_msg=f"Failed to {action} charging" + ) except Exception as error: _LOGGER.warning(f"Failed to {action} charging - {error}") self._requests["batterycharge"] = {"status": "Exception"} @@ -327,6 +366,8 @@ async def set_climatisation_temp(self, temperature=20): if 16 <= int(temperature) <= 30: temp = int((temperature + 273) * 10) data = {"action": {"settings": {"targetTemperature": temp}, "type": "setSettings"}} + elif 2885 <= int(temperature) <= 3030: + data = {"action": {"settings": {"targetTemperature": temperature}, "type": "setSettings"}} else: _LOGGER.error(f"Set climatisation target temp to {temperature} is not supported.") raise Exception(f"Set climatisation target temp to {temperature} is not supported.") @@ -392,34 +433,14 @@ async def set_climater(self, data, spin=False): if not self._services.get("rclima_v1", False): _LOGGER.info("Remote control of climatisation functions is not supported.") raise Exception("Remote control of climatisation functions is not supported.") - if self._requests["climatisation"].get("id", False): - timestamp = self._requests.get("climatisation", {}).get("timestamp", datetime.now()) - expired = datetime.now() - timedelta(minutes=3) - if expired > timestamp: - self._requests.get("climatisation", {}).pop("id") - else: - _LOGGER.debug("A climatisation action is already in progress") - return False + if self._in_progress("climatisation"): + return False try: self._requests["latest"] = "Climatisation" response = await self._connection.setClimater(self.vin, data, spin) - if not response: - self._requests["climatisation"] = {"status": "Failed"} - _LOGGER.error("Failed to execute climatisation request") - raise Exception("Failed to execute climatisation request") - else: - self._requests["remaining"] = response.get("rate_limit_remaining", -1) - self._requests["climatisation"] = { - "timestamp": datetime.now(), - "status": response.get("state", "Unknown"), - "id": response.get("id", 0), - } - if response.get("state", None) == "Throttled": - status = "Throttled" - else: - status = await self.wait_for_request("climatisation", response.get("id", 0)) - self._requests["climatisation"] = {"status": status} - return True + return await self._handle_response( + response=response, topic="climatisation", error_msg="Failed to execute climatisation request" + ) except Exception as error: _LOGGER.warning(f"Failed to execute climatisation request - {error}") self._requests["climatisation"] = {"status": "Exception"} @@ -431,14 +452,8 @@ async def set_pheater(self, mode, spin): if not self.is_pheater_heating_supported: _LOGGER.error("No parking heater support.") raise Exception("No parking heater support.") - if self._requests["preheater"].get("id", False): - timestamp = self._requests.get("preheater", {}).get("timestamp", datetime.now()) - expired = datetime.now() - timedelta(minutes=3) - if expired > timestamp: - self._requests.get("preheater", {}).pop("id") - else: - _LOGGER.debug("A parking heater action is already in progress") - return False + if self._in_progress("preheater"): + return False if mode not in ["heating", "ventilation", "off"]: _LOGGER.error(f"{mode} is an invalid action for parking heater") raise Exception(f"{mode} is an invalid action for parking heater") @@ -453,23 +468,9 @@ async def set_pheater(self, mode, spin): try: self._requests["latest"] = "Preheater" response = await self._connection.setPreHeater(self.vin, data, spin) - if not response: - self._requests["preheater"] = {"status": "Failed"} - _LOGGER.error(f"Failed to set parking heater to {mode}") - raise Exception(f'setPreHeater returned "{response}"') - else: - self._requests["remaining"] = response.get("rate_limit_remaining", -1) - self._requests["preheater"] = { - "timestamp": datetime.now(), - "status": response.get("state", "Unknown"), - "id": response.get("id", 0), - } - if response.get("state", None) == "Throttled": - status = "Throttled" - else: - status = await self.wait_for_request("rs", response.get("id", 0)) - self._requests["preheater"] = {"status": status} - return True + return await self._handle_response( + response=response, topic="preheater", error_msg=f"Failed to set parking heater to {mode}" + ) except Exception as error: _LOGGER.warning(f"Failed to set parking heater mode to {mode} - {error}") self._requests["preheater"] = {"status": "Exception"} @@ -481,14 +482,8 @@ async def set_lock(self, action, spin): if not self._services.get("rlu_v1", False): _LOGGER.info("Remote lock/unlock is not supported.") raise Exception("Remote lock/unlock is not supported.") - if self._requests["lock"].get("id", False): - timestamp = self._requests.get("lock", {}).get("timestamp", datetime.now() - timedelta(minutes=5)) - expired = datetime.now() - timedelta(minutes=3) - if expired > timestamp: - self._requests.get("lock", {}).pop("id") - else: - _LOGGER.debug("A lock action is already in progress") - return False + if self._in_progress("lock", unknown_offset=-5): + return False if action in ["lock", "unlock"]: data = '' + action + "" else: @@ -497,23 +492,7 @@ async def set_lock(self, action, spin): try: self._requests["latest"] = "Lock" response = await self._connection.setLock(self.vin, data, spin) - if not response: - self._requests["lock"] = {"status": "Failed"} - _LOGGER.error(f"Failed to {action} vehicle") - raise Exception(f"Failed to {action} vehicle") - else: - self._requests["remaining"] = response.get("rate_limit_remaining", -1) - self._requests["lock"] = { - "timestamp": datetime.now(), - "status": response.get("state", "Unknown"), - "id": response.get("id", 0), - } - if response.get("state", None) == "Throttled": - status = "Throttled" - else: - status = await self.wait_for_request("rlu", response.get("id", 0)) - self._requests["lock"] = {"status": status} - return True + return await self._handle_response(response=response, topic="lock", error_msg=f"Failed to {action} vehicle") except Exception as error: _LOGGER.warning(f"Failed to {action} vehicle - {error}") self._requests["lock"] = {"status": "Exception"} @@ -525,34 +504,14 @@ async def set_refresh(self): if not self._services.get("statusreport_v1", {}).get("active", False): _LOGGER.info("Data refresh is not supported.") raise Exception("Data refresh is not supported.") - if self._requests["refresh"].get("id", False): - timestamp = self._requests.get("refresh", {}).get("timestamp", datetime.now() - timedelta(minutes=5)) - expired = datetime.now() - timedelta(minutes=3) - if expired > timestamp: - self._requests.get("refresh", {}).pop("id") - else: - _LOGGER.debug("A data refresh request is already in progress") - return False + if self._in_progress("refresh", unknown_offset=-5): + return False try: self._requests["latest"] = "Refresh" response = await self._connection.setRefresh(self.vin) - if not response: - _LOGGER.error("Failed to request vehicle update") - self._requests["refresh"] = {"status": "Failed"} - raise Exception("Failed to execute data refresh") - else: - self._requests["remaining"] = response.get("rate_limit_remaining", -1) - self._requests["refresh"] = { - "timestamp": datetime.now(), - "status": response.get("status", "Unknown"), - "id": response.get("id", 0), - } - if response.get("state", None) == "Throttled": - status = "Throttled" - else: - status = await self.wait_for_request("vsr", response.get("id", 0)) - self._requests["refresh"] = {"status": status} - return True + return await self._handle_response( + response=response, topic="refresh", error_msg="Failed to request vehicle update" + ) except Exception as error: _LOGGER.warning(f"Failed to execute data refresh - {error}") self._requests["refresh"] = {"status": "Exception"} @@ -563,34 +522,14 @@ async def set_schedule(self, data: TimerData): if not self._services.get("timerprogramming_v1", False): _LOGGER.info("Remote control of timer functions is not supported.") raise Exception("Remote control of timer functions is not supported.") - if self._requests["departuretimer"].get("id", False): - timestamp = self._requests.get("departuretimer", {}).get("timestamp", datetime.now()) - expired = datetime.now() - timedelta(minutes=3) - if expired > timestamp: - self._requests.get("departuretimer", {}).pop("id") - else: - _LOGGER.debug("A timer action is already in progress") - return False + if self._in_progress("departuretimer"): + return False try: self._requests["latest"] = "Departuretimer" - response = await self._connection.setSchedule(self.vin, data) - if not response: - self._requests["departuretimer"] = {"status": "Failed"} - _LOGGER.error("Failed to execute timer request") - raise Exception("Failed to execute timer request") - else: - self._requests["remaining"] = response.get("rate_limit_remaining", -1) - self._requests["departuretimer"] = { - "timestamp": datetime.now(), - "status": response.get("state", "Unknown"), - "id": response.get("id", 0), - } - if response.get("state", None) == "Throttled": - status = "Throttled" - else: - status = await self.wait_for_request("departuretimer", response.get("id", 0)) - self._requests["departuretimer"] = {"status": status} - return True + response = await self._connection.setTimersAndProfiles(self.vin, data.timersAndProfiles) + return await self._handle_response( + response=response, topic="departuretimer", error_msg="Failed to execute timer request" + ) except Exception as error: _LOGGER.warning(f"Failed to execute timer request - {error}") self._requests["timer"] = {"status": "Exception"} @@ -1378,7 +1317,7 @@ def window_heater(self) -> bool: @property def is_window_heater_supported(self) -> bool: - """Return true if vehichle has heater.""" + """Return true if vehicle has heater.""" if self.is_electric_climatisation_supported: if self.attrs.get("climater", {}).get("status", {}).get("windowHeatingStatusData", {}).get( "windowHeatingStateFront", {} @@ -1426,7 +1365,7 @@ def pheater_ventilation(self): @property def is_pheater_ventilation_supported(self) -> bool: - """Return true if vehichle has combustion climatisation.""" + """Return true if vehicle has combustion climatisation.""" return self.is_pheater_heating_supported @property @@ -1439,7 +1378,7 @@ def pheater_heating(self) -> bool: @property def is_pheater_heating_supported(self) -> bool: - """Return true if vehichle has combustion engine heating.""" + """Return true if vehicle has combustion engine heating.""" return self.attrs.get("heating", {}).get("climatisationStateReport", {}).get("climatisationState", False) @property @@ -1449,7 +1388,7 @@ def pheater_status(self) -> str: @property def is_pheater_status_supported(self) -> bool: - """Return true if vehichle has combustion engine heating/ventilation.""" + """Return true if vehicle has combustion engine heating/ventilation.""" return self.attrs.get("heating", {}).get("climatisationStateReport", {}).get("climatisationState", False) # Windows @@ -1770,6 +1709,30 @@ def schedule(self, schedule_id: Union[str, int]) -> Optional[Timer]: timer: TimerData = self.attrs.get("timer", None) return timer.get_schedule(schedule_id) + @property + def schedule_min_charge_level(self) -> int: + """Get charge minimum level.""" + timer: TimerData = self.attrs.get("timer") + return timer.timersAndProfiles.timerBasicSetting.chargeMinLimit + + @property + def is_schedule_min_charge_level_supported(self) -> bool: + """Check if charge minimum level is supported.""" + timer: TimerData = self.attrs.get("timer", None) + return timer.timersAndProfiles.timerBasicSetting.chargeMinLimit is not None + + @property + def timer_basic_settings(self) -> BasicSettings: + """Check if timer basic settings are supported.""" + timer: TimerData = self.attrs.get("timer") + return timer.timersAndProfiles.timerBasicSetting + + @property + def is_timer_basic_settings_supported(self) -> bool: + """Check if timer basic settings are supported.""" + timer: TimerData = self.attrs.get("timer", None) + return timer.timersAndProfiles.timerBasicSetting is not None + @property def is_departure_timer1_supported(self) -> bool: """Check if timer 1 is supported.""" @@ -2082,7 +2045,7 @@ def requests_remaining(self, value): @property def is_requests_remaining_supported(self): """ - Return true if requests remaining is supperted. + Return true if requests remaining is supported. :return: """