From c35944ac149476f7aa3ad3609b71011436be967c Mon Sep 17 00:00:00 2001 From: Philipp Waller <1090452+philippwaller@users.noreply.github.com> Date: Mon, 7 Oct 2024 21:38:00 +0200 Subject: [PATCH 1/9] feature: add client method "preheat_start_universal" --- custom_components/mbapi2020/client.py | 7 ++++ custom_components/mbapi2020/switch.py | 58 +++++++++++++++------------ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/custom_components/mbapi2020/client.py b/custom_components/mbapi2020/client.py index 68677897..fcef8c04 100644 --- a/custom_components/mbapi2020/client.py +++ b/custom_components/mbapi2020/client.py @@ -1261,6 +1261,13 @@ async def preheat_start_immediate(self, vin: str): await self.websocket.call(message.SerializeToString()) LOGGER.info("End preheat_start_immediate for vin %s", loghelper.Mask_VIN(vin)) + async def preheat_start_universal(self,vin: str) -> None: + """Turn on preheat universally for any car model.""" + if self._is_car_feature_available(vin, "precondNow"): + await self.preheat_start(vin) + else: + await self.preheat_start_immediate(vin) + async def preheat_start_departure_time(self, vin: str, departure_time: int): """Send a preconditioning start by time command to the car.""" LOGGER.info("Start preheat_start_departure_time for vin %s", loghelper.Mask_VIN(vin)) diff --git a/custom_components/mbapi2020/switch.py b/custom_components/mbapi2020/switch.py index 86332326..280c9504 100644 --- a/custom_components/mbapi2020/switch.py +++ b/custom_components/mbapi2020/switch.py @@ -9,27 +9,24 @@ from dataclasses import dataclass from typing import Protocol -from homeassistant.components.switch import SwitchEntity +from config.custom_components.mbapi2020 import MercedesMeEntity, MercedesMeEntityConfig +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from . import MercedesMeEntity, MercedesMeEntityConfig -from .const import CONF_FT_DISABLE_CAPABILITY_CHECK, DOMAIN, LOGGER, STATE_CONFIRMATION_DURATION +from .const import ( + CONF_FT_DISABLE_CAPABILITY_CHECK, + DOMAIN, + LOGGER, + STATE_CONFIRMATION_DURATION, +) from .coordinator import MBAPI2020DataUpdateCoordinator from .helper import LogHelper as loghelper, check_capabilities -async def async_turn_on_preheat(self: MercedesMESwitch, **kwargs) -> None: - """Turn on preheat.""" - if self._car.features.get("precondNow"): - await self._coordinator.client.preheat_start(self._vin) - else: - await self._coordinator.client.preheat_start_immediate(self._vin) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -37,14 +34,18 @@ async def async_setup_entry( ) -> None: """Set up the switch platform for Mercedes ME.""" - coordinator: MBAPI2020DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: MBAPI2020DataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] if not coordinator.client.cars: LOGGER.info("No cars found during the switch creation process") return entities: list[MercedesMESwitch] = [] - skip_capability_check = config_entry.options.get(CONF_FT_DISABLE_CAPABILITY_CHECK, False) + skip_capability_check = config_entry.options.get( + CONF_FT_DISABLE_CAPABILITY_CHECK, False + ) for car in coordinator.client.cars.values(): car_vin_masked = loghelper.Mask_VIN(car.finorvin) @@ -67,7 +68,9 @@ async def async_setup_entry( continue try: - entity = MercedesMESwitch(config=config, vin=car.finorvin, coordinator=coordinator) + entity = MercedesMESwitch( + config=config, vin=car.finorvin, coordinator=coordinator + ) entities.append(entity) LOGGER.debug( "Created switch entity for car '%s': Internal Name='%s', Entity Name='%s'", @@ -108,7 +111,7 @@ def __call__(self) -> bool: @dataclass(frozen=True) -class MercedesMeSwitchEntityConfig(MercedesMeEntityConfig): +class MercedesMeSwitchConfig(MercedesMeEntityConfig): """Configuration class for MercedesMe switch entities.""" turn_on: SwitchTurnOn | None = None @@ -147,7 +150,7 @@ def __repr__(self) -> str: class MercedesMESwitch(MercedesMeEntity, SwitchEntity, RestoreEntity): """Representation of a Mercedes Me Switch.""" - def __init__(self, config: MercedesMeSwitchEntityConfig, vin, coordinator) -> None: + def __init__(self, config: MercedesMeSwitchConfig, vin, coordinator) -> None: """Initialize the switch with methods for handling on/off commands.""" self._turn_on_method = config.turn_on self._turn_off_method = config.turn_off @@ -235,28 +238,33 @@ def assumed_state(self) -> bool: """Return True if the state is being assumed during the confirmation duration.""" return self._expected_state is not None - -SWITCH_CONFIGS: list[MercedesMeSwitchEntityConfig] = [ - MercedesMeSwitchEntityConfig( - id="preheat", - entity_name="Preclimate", +SWITCH_CONFIGS: list[MercedesMeSwitchConfig] = [ + MercedesMeSwitchConfig( + id="pre_entry_climate_control", + entity_name="Pre-entry climate control", feature_name="precond", object_name="precondStatus", attribute_name="value", icon="mdi:hvac", - capability_check=lambda car: check_capabilities(car, ["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"]), - turn_on=async_turn_on_preheat, + device_class=SwitchDeviceClass.SWITCH, + turn_on=lambda self, **kwargs: self._coordinator.client.preheat_start_universal(self._vin), turn_off=lambda self, **kwargs: self._coordinator.client.preheat_stop(self._vin), + capability_check=lambda car: check_capabilities( + car, ["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"] + ), ), - MercedesMeSwitchEntityConfig( + MercedesMeSwitchConfig( id="auxheat", entity_name="Auxiliary Heating", feature_name="auxheat", object_name="auxheatActive", attribute_name="value", icon="mdi:hvac", - capability_check=lambda car: check_capabilities(car, ["AUXHEAT_START", "AUXHEAT_STOP"]), + device_class=SwitchDeviceClass.SWITCH, turn_on=lambda self, **kwargs: self._coordinator.client.auxheat_start(self._vin), turn_off=lambda self, **kwargs: self._coordinator.client.auxheat_stop(self._vin), + capability_check=lambda car: check_capabilities( + car, ["AUXHEAT_START", "AUXHEAT_STOP"] + ), ), ] From 2af05d63e91f89ac61fb290341a050e3a70d7e1f Mon Sep 17 00:00:00 2001 From: Philipp Waller <1090452+philippwaller@users.noreply.github.com> Date: Mon, 7 Oct 2024 22:17:01 +0200 Subject: [PATCH 2/9] refactor: align configuration classes with Home Assistant standards --- custom_components/mbapi2020/__init__.py | 103 +++----- custom_components/mbapi2020/car.py | 4 + custom_components/mbapi2020/helper.py | 5 - custom_components/mbapi2020/switch.py | 322 ++++++++++-------------- 4 files changed, 165 insertions(+), 269 deletions(-) diff --git a/custom_components/mbapi2020/__init__.py b/custom_components/mbapi2020/__init__.py index 7ef7a787..bebad22a 100644 --- a/custom_components/mbapi2020/__init__.py +++ b/custom_components/mbapi2020/__init__.py @@ -9,8 +9,6 @@ from typing import Protocol import aiohttp -import voluptuous as vol - from custom_components.mbapi2020.car import Car, CarAttribute, RcpOptions from custom_components.mbapi2020.const import ( ATTR_MB_MANUFACTURER, @@ -26,12 +24,18 @@ from custom_components.mbapi2020.errors import WebsocketError from custom_components.mbapi2020.helper import LogHelper as loghelper from custom_components.mbapi2020.services import setup_services +import voluptuous as vol + from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -69,7 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): raise ConfigEntryAuthFailed() masterdata = await coordinator.client.webapi.get_user_info() - hass.async_add_executor_job(coordinator.client.write_debug_json_output, masterdata, "md", True) + hass.async_add_executor_job(coordinator.client.write_debug_json_output, masterdata, "md") for car in masterdata.get("assignedVehicles"): # Check if the car has a separate VIN key, if not, use the FIN. @@ -89,12 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): try: car_capabilities = await coordinator.client.webapi.get_car_capabilities(vin) - hass.async_add_executor_job( - coordinator.client.write_debug_json_output, - car_capabilities, - f"cai-{loghelper.Mask_VIN(vin)}-", - True, - ) + hass.async_add_executor_job(coordinator.client.write_debug_json_output, car_capabilities, "cai") if car_capabilities and "features" in car_capabilities: features.update(car_capabilities["features"]) except aiohttp.ClientError: @@ -106,17 +105,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): try: capabilities = await coordinator.client.webapi.get_car_capabilities_commands(vin) - hass.async_add_executor_job( - coordinator.client.write_debug_json_output, capabilities, f"ca-{loghelper.Mask_VIN(vin)}-", True - ) + hass.async_add_executor_job(coordinator.client.write_debug_json_output, capabilities, "ca") if capabilities: for feature in capabilities.get("commands"): features[feature.get("commandName")] = bool(feature.get("isAvailable")) - if feature.get("commandName", "") == "ZEV_PRECONDITION_CONFIGURE_SEATS": - capabilityInformation = feature.get("capabilityInformation", None) - if capabilityInformation and len(capabilityInformation) > 0: - features[feature.get("capabilityInformation")[0]] = bool(feature.get("isAvailable")) - except aiohttp.ClientError: # For some cars a HTTP401 is raised when asking for capabilities, see github issue #83 # We just ignore the capabilities @@ -231,47 +223,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): return unload_ok -class CapabilityCheckFunc(Protocol): - """Protocol for a callable that checks if a capability is available for a given car.""" - - def __call__(self, car: Car) -> bool: - """Check if the capability is available for the specified car.""" - - -@dataclass(frozen=True) -class MercedesMeEntityConfig: - """Configuration class for MercedesMe entities.""" - - id: str - entity_name: str - feature_name: str - object_name: str - attribute_name: str - - attributes: list[str] | None = None - icon: str | None = None - device_class: str | None = None - entity_category: EntityCategory | None = None - - capability_check: CapabilityCheckFunc | None = None - - def __repr__(self) -> str: - """Return a string representation of the MercedesMeEntityConfig instance.""" - return ( - f"{self.__class__.__name__}(" - f"internal_name={self.id!r}, " - f"entity_name={self.entity_name!r}, " - f"feature_name={self.feature_name!r}, " - f"object_name={self.object_name!r}, " - f"attribute_name={self.attribute_name!r}, " - f"capability_check={self.capability_check!r}, " - f"attributes={self.attributes!r}, " - f"device_class={self.device_class!r}, " - f"icon={self.icon!r}, " - f"entity_category={self.entity_category!r})" - ) - - class MercedesMeEntity(CoordinatorEntity[MBAPI2020DataUpdateCoordinator], Entity): """Entity class for MercedesMe devices.""" @@ -280,7 +231,7 @@ class MercedesMeEntity(CoordinatorEntity[MBAPI2020DataUpdateCoordinator], Entity def __init__( self, internal_name: str, - config: list | MercedesMeEntityConfig, + config: list | EntityDescription, vin: str, coordinator: MBAPI2020DataUpdateCoordinator, should_poll: bool = False, @@ -297,12 +248,12 @@ def __init__( self._state = None # Temporary workaround: If PR get's approved, all entity types should be migrated to the new config classes - if isinstance(config, MercedesMeEntityConfig): - self._sensor_name = config.entity_name + if isinstance(config, EntityDescription): + self._sensor_name = config.translation_key self._internal_unit = None - self._feature_name = config.feature_name - self._object_name = config.object_name - self._attrib_name = config.attribute_name + self._feature_name = None + self._object_name = None + self._attrib_name = None self._flip_result = False self._attr_device_class = config.device_class self._attr_icon = config.icon @@ -404,15 +355,29 @@ def unit_of_measurement(self): ) return reported_unit - if isinstance(self._sensor_config, MercedesMeEntityConfig): + if isinstance(self._sensor_config, EntityDescription): return None return self._sensor_config[scf.UNIT_OF_MEASUREMENT.value] def update(self): """Get the latest data and updates the states.""" + if not self.enabled: + return + + if isinstance(self._sensor_config, EntityDescription): + try: + self._mercedes_me_update() + except Exception as err: + LOGGER.error("Error while updating entity %s: %s", self.name, err) + else: + self._state = self._get_car_value( + self._feature_name, self._object_name, self._attrib_name, "error" + ) + self.async_write_ha_state() - self._state = self._get_car_value(self._feature_name, self._object_name, self._attrib_name, "error") - self.async_write_ha_state() + def _mercedes_me_update(self) -> None: + """Update Mercedes Me entity.""" + raise NotImplementedError def _get_car_value(self, feature, object_name, attrib_name, default_value): value = None diff --git a/custom_components/mbapi2020/car.py b/custom_components/mbapi2020/car.py index 86d54e06..d7d5f16e 100644 --- a/custom_components/mbapi2020/car.py +++ b/custom_components/mbapi2020/car.py @@ -295,6 +295,10 @@ def publish_updates(self): for callback in self._update_listeners: callback() + def check_capabilities(self, required_capabilities: list[str]) -> bool: + """Check if the car has the required capabilities.""" + return all(self.features.get(capability) is True for capability in required_capabilities) + @dataclass(init=False) class Tires: diff --git a/custom_components/mbapi2020/helper.py b/custom_components/mbapi2020/helper.py index c9905e0d..35ae13f8 100644 --- a/custom_components/mbapi2020/helper.py +++ b/custom_components/mbapi2020/helper.py @@ -229,8 +229,3 @@ def default(self, o) -> Union[str, dict]: # noqa: D102 retval.update({p: getattr(o, p) for p in get_class_property_names(o)}) return {k: v for k, v in retval.items() if k not in JSON_EXPORT_IGNORED_KEYS} return str(o) - - -def check_capabilities(car, required_capabilities): - """Check if the car has the required capabilities.""" - return all(car.features.get(capability) is True for capability in required_capabilities) diff --git a/custom_components/mbapi2020/switch.py b/custom_components/mbapi2020/switch.py index 280c9504..a17d5a90 100644 --- a/custom_components/mbapi2020/switch.py +++ b/custom_components/mbapi2020/switch.py @@ -6,11 +6,13 @@ from __future__ import annotations +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Protocol +from typing import Any -from config.custom_components.mbapi2020 import MercedesMeEntity, MercedesMeEntityConfig -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from config.custom_components.mbapi2020 import MercedesMeEntity +from config.custom_components.mbapi2020.car import Car +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,144 +26,57 @@ STATE_CONFIRMATION_DURATION, ) from .coordinator import MBAPI2020DataUpdateCoordinator -from .helper import LogHelper as loghelper, check_capabilities +from .helper import LogHelper as loghelper -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the switch platform for Mercedes ME.""" - - coordinator: MBAPI2020DataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] - - if not coordinator.client.cars: - LOGGER.info("No cars found during the switch creation process") - return - - entities: list[MercedesMESwitch] = [] - skip_capability_check = config_entry.options.get( - CONF_FT_DISABLE_CAPABILITY_CHECK, False - ) - - for car in coordinator.client.cars.values(): - car_vin_masked = loghelper.Mask_VIN(car.finorvin) - - for config in SWITCH_CONFIGS: - capability_check = getattr(config, "capability_check", None) - if capability_check is None: - LOGGER.error( - "Missing capability check for switch config '%s'. Skipping", - config.id, - ) - continue - - if not skip_capability_check and not capability_check(car): - LOGGER.debug( - "Car '%s' does not support feature '%s'. Skipping", - car_vin_masked, - config.id, - ) - continue - - try: - entity = MercedesMESwitch( - config=config, vin=car.finorvin, coordinator=coordinator - ) - entities.append(entity) - LOGGER.debug( - "Created switch entity for car '%s': Internal Name='%s', Entity Name='%s'", - car_vin_masked, - config.id, - config.entity_name, - ) - except Exception as e: - LOGGER.error( - "Error creating switch entity '%s' for car '%s': %s", - config.id, - car_vin_masked, - str(e), - ) - - async_add_entities(entities) - - -class SwitchTurnOn(Protocol): - """Protocol for a callable that asynchronously turns on a MercedesME switch.""" - - async def __call__(self, **kwargs) -> None: - """Asynchronously turn on the switch.""" - - -class SwitchTurnOff(Protocol): - """Protocol for a callable that asynchronously turns off a MercedesME switch.""" - - async def __call__(self, **kwargs) -> None: - """Asynchronously turn off the switch.""" - - -class SwitchIsOn(Protocol): - """Protocol for a callable that checks if a MercedesME switch is on.""" - - def __call__(self) -> bool: - """Check if the switch is currently on.""" - - -@dataclass(frozen=True) -class MercedesMeSwitchConfig(MercedesMeEntityConfig): +@dataclass(frozen=True, kw_only=True) +class MercedesMeSwitchEntityDescription(SwitchEntityDescription): """Configuration class for MercedesMe switch entities.""" - turn_on: SwitchTurnOn | None = None - turn_off: SwitchTurnOff | None = None - is_on: SwitchIsOn | None = None - - def __post_init__(self): - """Post-initialization checks to ensure required fields are set.""" - if self.capability_check is None: - raise ValueError(f"capability_check is required for {self.__class__.__name__}") - if self.turn_on is None: - raise ValueError(f"turn_on is required for {self.__class__.__name__}") - if self.turn_off is None: - raise ValueError(f"turn_off is required for {self.__class__.__name__}") - - def __repr__(self) -> str: - """Return a string representation of the MercedesMeSwitchEntityConfig instance.""" - return ( - f"{self.__class__.__name__}(" - f"internal_name={self.id!r}, " - f"entity_name={self.entity_name!r}, " - f"feature_name={self.feature_name!r}, " - f"object_name={self.object_name!r}, " - f"attribute_name={self.attribute_name!r}, " - f"capability_check={self.capability_check!r}, " - f"attributes={self.attributes!r}, " - f"device_class={self.device_class!r}, " - f"icon={self.icon!r}, " - f"entity_category={self.entity_category!r}, " - f"turn_on={self.turn_on!r}, " - f"turn_off={self.turn_off!r}, " - f"is_on={self.is_on!r})" - ) + attributes: list[str] | None = None + is_on_fn: Callable[[MercedesMESwitch], Callable[[], Coroutine[Any, Any, bool]]] + turn_on_fn: Callable[[MercedesMESwitch], Callable[[], Coroutine[Any, Any, None]]] + turn_off_fn: Callable[[MercedesMESwitch], Callable[[], Coroutine[Any, Any, None]]] + check_capability_fn: Callable[[Car], Callable[[], Coroutine[Any, Any, bool]]] +SWITCH_CONFIGS: list[MercedesMeSwitchEntityDescription] = [ + MercedesMeSwitchEntityDescription( + key="precond", + translation_key="Pre-entry climate control", + icon="mdi:hvac", + is_on_fn=lambda self: self._get_car_value("precond", "precondStatus", "value", default_value=False), + turn_on_fn=lambda self,**kwargs: self._coordinator.client.preheat_start_universal(self._vin), + turn_off_fn=lambda self, **kwargs: self._coordinator.client.preheat_stop(self._vin), + check_capability_fn=lambda car: car.check_capabilities(["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"]), + ), + MercedesMeSwitchEntityDescription( + key="auxheat", + translation_key="Auxiliary Heating", + icon="mdi:hvac", + is_on_fn=lambda self: self._get_car_value("auxheat", "auxheatActive", "value", default_value=False), + turn_on_fn=lambda self, **kwargs: self._coordinator.client.auxheat_start(self._vin), + turn_off_fn=lambda self, **kwargs: self._coordinator.client.auxheat_stop(self._vin), + check_capability_fn=lambda car: car.check_capabilities(["AUXHEAT_START", "AUXHEAT_STOP"]), + ), +] class MercedesMESwitch(MercedesMeEntity, SwitchEntity, RestoreEntity): """Representation of a Mercedes Me Switch.""" - def __init__(self, config: MercedesMeSwitchConfig, vin, coordinator) -> None: + _entity_description: MercedesMeSwitchEntityDescription + + def __init__( + self, description: MercedesMeSwitchEntityDescription, vin, coordinator + ) -> None: """Initialize the switch with methods for handling on/off commands.""" - self._turn_on_method = config.turn_on - self._turn_off_method = config.turn_off - self._is_on_method = config.is_on + self._entity_description = description # Initialize command tracking variables self._expected_state = None # True for on, False for off, or None self._state_confirmation_duration = STATE_CONFIRMATION_DURATION self._confirmation_handle = None - super().__init__(config.id, config, vin, coordinator) + super().__init__(description.key, description, vin, coordinator) async def async_turn_on(self, **kwargs: dict) -> None: """Turn the device component on.""" @@ -176,34 +91,48 @@ async def _async_handle_state_change(self, state: bool, **kwargs) -> None: # Set the expected state based on the desired state self._expected_state = state - # Execute the appropriate method based on the desired state - if state: - await self._turn_on_method(self, **kwargs) - else: - await self._turn_off_method(self, **kwargs) - - # Cancel previous confirmation if any - if self._confirmation_handle: - self._confirmation_handle() - - # Schedule state reset after confirmation duration - self._confirmation_handle = async_call_later( - self.hass, self._state_confirmation_duration, self._reset_expected_state - ) - - # Update the UI - self.async_write_ha_state() + try: + # Execute the appropriate method and handle any exceptions + if state: + await self._entity_description.turn_on_fn(self, **kwargs) + else: + await self._entity_description.turn_off_fn(self, **kwargs) + + # Cancel any existing confirmation handle + if self._confirmation_handle: + self._confirmation_handle() + + # Schedule state reset after confirmation duration + self._confirmation_handle = async_call_later( + self.hass, self._state_confirmation_duration, self._reset_expected_state + ) + + except Exception as e: + # Log the error and reset state if needed + LOGGER.error( + "Error changing state to %s: %s", "on" if state else "off", str(e) + ) + self._expected_state = None + if self._confirmation_handle: + self._confirmation_handle() + self._confirmation_handle = None + self.async_write_ha_state() async def _reset_expected_state(self, _): """Reset the expected state after confirmation duration and update the state.""" + self._attr_is_on = not self._expected_state self._expected_state = None self._confirmation_handle = None self.async_write_ha_state() - @property - def is_on(self) -> bool: - """Return True if the device is on.""" - actual_state = self._get_actual_state() + def _mercedes_me_update(self) -> None: + """Update Mercedes Me entity.""" + try: + actual_state = self._entity_description.is_on_fn(self) + except Exception as e: + LOGGER.error("Error getting actual state for %s: %s", self.name, str(e)) + self._attr_available = False + return if self._expected_state is not None: if actual_state == self._expected_state: @@ -214,57 +143,60 @@ def is_on(self) -> bool: self._expected_state = None else: # Return expected state during the confirmation duration - return self._expected_state - - return actual_state - - def _get_actual_state(self) -> bool: - """Return the actual state of the device.""" - if self._is_on_method: - return self._is_on_method() - return self._default_is_on() - - def _default_is_on(self) -> bool: - """Provide default implementation for determining the 'on' state.""" - return self._get_car_value( - self._feature_name, - self._object_name, - self._attrib_name, - default_value=False, - ) + self._attr_is_on = self._expected_state + else: + self._attr_is_on = actual_state + self.async_write_ha_state() @property def assumed_state(self) -> bool: - """Return True if the state is being assumed during the confirmation duration.""" + """Return True if the state is assumed.""" return self._expected_state is not None -SWITCH_CONFIGS: list[MercedesMeSwitchConfig] = [ - MercedesMeSwitchConfig( - id="pre_entry_climate_control", - entity_name="Pre-entry climate control", - feature_name="precond", - object_name="precondStatus", - attribute_name="value", - icon="mdi:hvac", - device_class=SwitchDeviceClass.SWITCH, - turn_on=lambda self, **kwargs: self._coordinator.client.preheat_start_universal(self._vin), - turn_off=lambda self, **kwargs: self._coordinator.client.preheat_stop(self._vin), - capability_check=lambda car: check_capabilities( - car, ["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"] - ), - ), - MercedesMeSwitchConfig( - id="auxheat", - entity_name="Auxiliary Heating", - feature_name="auxheat", - object_name="auxheatActive", - attribute_name="value", - icon="mdi:hvac", - device_class=SwitchDeviceClass.SWITCH, - turn_on=lambda self, **kwargs: self._coordinator.client.auxheat_start(self._vin), - turn_off=lambda self, **kwargs: self._coordinator.client.auxheat_stop(self._vin), - capability_check=lambda car: check_capabilities( - car, ["AUXHEAT_START", "AUXHEAT_STOP"] - ), - ), -] +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the switch platform for Mercedes ME.""" + + coordinator: MBAPI2020DataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + if not coordinator.client.cars: + LOGGER.info("No cars found during the switch creation process") + return + + entities: list[MercedesMESwitch] = [] + skip_capability_check = config_entry.options.get(CONF_FT_DISABLE_CAPABILITY_CHECK, False) + + for car in coordinator.client.cars.values(): + vin_masked = loghelper.Mask_VIN(car.finorvin) + + for description in SWITCH_CONFIGS: + if description.check_capability_fn is None: + LOGGER.error("Missing capability check for switch config '%s'. Skipping", description.key) + continue + + if not skip_capability_check and not description.check_capability_fn(car): + LOGGER.debug("Car '%s' does not support feature '%s'. Skipping", vin_masked, description.key) + continue + + try: + entities.append(MercedesMESwitch(description, car.finorvin, coordinator)) + LOGGER.debug( + "Created switch entity for car '%s': Internal Name='%s', Entity Name='%s'", + vin_masked, + description.key, + description.translation_key, + ) + except Exception as e: + LOGGER.error( + "Error creating switch entity '%s' for car '%s': %s", + description.key, + vin_masked, + str(e), + ) + + async_add_entities(entities) From e82b3d8d992f70efbbd3c66d4812a66b85bea389 Mon Sep 17 00:00:00 2001 From: Philipp Waller <1090452+philippwaller@users.noreply.github.com> Date: Fri, 11 Oct 2024 08:20:30 +0200 Subject: [PATCH 3/9] chore: add translations --- custom_components/mbapi2020/switch.py | 4 ++-- custom_components/mbapi2020/translations/de.json | 10 ++++++++++ custom_components/mbapi2020/translations/en.json | 8 ++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/custom_components/mbapi2020/switch.py b/custom_components/mbapi2020/switch.py index a17d5a90..087f52e1 100644 --- a/custom_components/mbapi2020/switch.py +++ b/custom_components/mbapi2020/switch.py @@ -42,7 +42,7 @@ class MercedesMeSwitchEntityDescription(SwitchEntityDescription): SWITCH_CONFIGS: list[MercedesMeSwitchEntityDescription] = [ MercedesMeSwitchEntityDescription( key="precond", - translation_key="Pre-entry climate control", + translation_key="precond", icon="mdi:hvac", is_on_fn=lambda self: self._get_car_value("precond", "precondStatus", "value", default_value=False), turn_on_fn=lambda self,**kwargs: self._coordinator.client.preheat_start_universal(self._vin), @@ -51,7 +51,7 @@ class MercedesMeSwitchEntityDescription(SwitchEntityDescription): ), MercedesMeSwitchEntityDescription( key="auxheat", - translation_key="Auxiliary Heating", + translation_key="auxheat", icon="mdi:hvac", is_on_fn=lambda self: self._get_car_value("auxheat", "auxheatActive", "value", default_value=False), turn_on_fn=lambda self, **kwargs: self._coordinator.client.auxheat_start(self._vin), diff --git a/custom_components/mbapi2020/translations/de.json b/custom_components/mbapi2020/translations/de.json index d88d4152..05ba0570 100644 --- a/custom_components/mbapi2020/translations/de.json +++ b/custom_components/mbapi2020/translations/de.json @@ -47,5 +47,15 @@ } } } + }, + "entity": { + "switch": { + "auxheat": { + "name": "Standheizung" + }, + "precond": { + "name": "Vorklimatisierung" + } + } } } diff --git a/custom_components/mbapi2020/translations/en.json b/custom_components/mbapi2020/translations/en.json index 4fc236e6..01986f27 100755 --- a/custom_components/mbapi2020/translations/en.json +++ b/custom_components/mbapi2020/translations/en.json @@ -689,6 +689,14 @@ "3": "Deflation" } } + }, + "switch": { + "auxheat": { + "name": "Auxiliary Heating" + }, + "precond": { + "name": "Pre-entry climate control" + } } }, "selector": { From 767e05bd39a28447843d1d5a423bdd48bd085de7 Mon Sep 17 00:00:00 2001 From: Rene Nulsch Date: Sat, 12 Oct 2024 18:14:34 +0200 Subject: [PATCH 4/9] fix: Correct Imports and linting --- custom_components/mbapi2020/switch.py | 32 +++++++++++---------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/custom_components/mbapi2020/switch.py b/custom_components/mbapi2020/switch.py index 087f52e1..deb68e6e 100644 --- a/custom_components/mbapi2020/switch.py +++ b/custom_components/mbapi2020/switch.py @@ -10,8 +10,6 @@ from dataclasses import dataclass from typing import Any -from config.custom_components.mbapi2020 import MercedesMeEntity -from config.custom_components.mbapi2020.car import Car from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -19,12 +17,9 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from .const import ( - CONF_FT_DISABLE_CAPABILITY_CHECK, - DOMAIN, - LOGGER, - STATE_CONFIRMATION_DURATION, -) +from . import MercedesMeEntity +from .car import Car +from .const import CONF_FT_DISABLE_CAPABILITY_CHECK, DOMAIN, LOGGER, STATE_CONFIRMATION_DURATION from .coordinator import MBAPI2020DataUpdateCoordinator from .helper import LogHelper as loghelper @@ -39,15 +34,18 @@ class MercedesMeSwitchEntityDescription(SwitchEntityDescription): turn_off_fn: Callable[[MercedesMESwitch], Callable[[], Coroutine[Any, Any, None]]] check_capability_fn: Callable[[Car], Callable[[], Coroutine[Any, Any, bool]]] + SWITCH_CONFIGS: list[MercedesMeSwitchEntityDescription] = [ MercedesMeSwitchEntityDescription( key="precond", translation_key="precond", icon="mdi:hvac", is_on_fn=lambda self: self._get_car_value("precond", "precondStatus", "value", default_value=False), - turn_on_fn=lambda self,**kwargs: self._coordinator.client.preheat_start_universal(self._vin), + turn_on_fn=lambda self, **kwargs: self._coordinator.client.preheat_start_universal(self._vin), turn_off_fn=lambda self, **kwargs: self._coordinator.client.preheat_stop(self._vin), - check_capability_fn=lambda car: car.check_capabilities(["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"]), + check_capability_fn=lambda car: car.check_capabilities( + ["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"] + ), ), MercedesMeSwitchEntityDescription( key="auxheat", @@ -60,14 +58,13 @@ class MercedesMeSwitchEntityDescription(SwitchEntityDescription): ), ] + class MercedesMESwitch(MercedesMeEntity, SwitchEntity, RestoreEntity): """Representation of a Mercedes Me Switch.""" _entity_description: MercedesMeSwitchEntityDescription - def __init__( - self, description: MercedesMeSwitchEntityDescription, vin, coordinator - ) -> None: + def __init__(self, description: MercedesMeSwitchEntityDescription, vin, coordinator) -> None: """Initialize the switch with methods for handling on/off commands.""" self._entity_description = description @@ -109,9 +106,7 @@ async def _async_handle_state_change(self, state: bool, **kwargs) -> None: except Exception as e: # Log the error and reset state if needed - LOGGER.error( - "Error changing state to %s: %s", "on" if state else "off", str(e) - ) + LOGGER.error("Error changing state to %s: %s", "on" if state else "off", str(e)) self._expected_state = None if self._confirmation_handle: self._confirmation_handle() @@ -153,6 +148,7 @@ def assumed_state(self) -> bool: """Return True if the state is assumed.""" return self._expected_state is not None + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -160,9 +156,7 @@ async def async_setup_entry( ) -> None: """Set up the switch platform for Mercedes ME.""" - coordinator: MBAPI2020DataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator: MBAPI2020DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] if not coordinator.client.cars: LOGGER.info("No cars found during the switch creation process") From 9655a5f988e026a9a661ddfbe057d1c433d16686 Mon Sep 17 00:00:00 2001 From: Rene Nulsch Date: Sat, 12 Oct 2024 18:19:46 +0200 Subject: [PATCH 5/9] fix: restore changes --- custom_components/mbapi2020/__init__.py | 31 ++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/custom_components/mbapi2020/__init__.py b/custom_components/mbapi2020/__init__.py index bebad22a..e632203f 100644 --- a/custom_components/mbapi2020/__init__.py +++ b/custom_components/mbapi2020/__init__.py @@ -9,6 +9,8 @@ from typing import Protocol import aiohttp +import voluptuous as vol + from custom_components.mbapi2020.car import Car, CarAttribute, RcpOptions from custom_components.mbapi2020.const import ( ATTR_MB_MANUFACTURER, @@ -24,16 +26,10 @@ from custom_components.mbapi2020.errors import WebsocketError from custom_components.mbapi2020.helper import LogHelper as loghelper from custom_components.mbapi2020.services import setup_services -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import ConfigType @@ -73,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): raise ConfigEntryAuthFailed() masterdata = await coordinator.client.webapi.get_user_info() - hass.async_add_executor_job(coordinator.client.write_debug_json_output, masterdata, "md") + hass.async_add_executor_job(coordinator.client.write_debug_json_output, masterdata, "md", True) for car in masterdata.get("assignedVehicles"): # Check if the car has a separate VIN key, if not, use the FIN. @@ -93,7 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): try: car_capabilities = await coordinator.client.webapi.get_car_capabilities(vin) - hass.async_add_executor_job(coordinator.client.write_debug_json_output, car_capabilities, "cai") + hass.async_add_executor_job( + coordinator.client.write_debug_json_output, + car_capabilities, + f"cai-{loghelper.Mask_VIN(vin)}-", + True, + ) if car_capabilities and "features" in car_capabilities: features.update(car_capabilities["features"]) except aiohttp.ClientError: @@ -105,10 +106,16 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): try: capabilities = await coordinator.client.webapi.get_car_capabilities_commands(vin) - hass.async_add_executor_job(coordinator.client.write_debug_json_output, capabilities, "ca") + hass.async_add_executor_job( + coordinator.client.write_debug_json_output, capabilities, f"ca-{loghelper.Mask_VIN(vin)}-", True + ) if capabilities: for feature in capabilities.get("commands"): features[feature.get("commandName")] = bool(feature.get("isAvailable")) + if feature.get("commandName", "") == "ZEV_PRECONDITION_CONFIGURE_SEATS": + capabilityInformation = feature.get("capabilityInformation", None) + if capabilityInformation and len(capabilityInformation) > 0: + features[feature.get("capabilityInformation")[0]] = bool(feature.get("isAvailable")) except aiohttp.ClientError: # For some cars a HTTP401 is raised when asking for capabilities, see github issue #83 # We just ignore the capabilities @@ -370,9 +377,7 @@ def update(self): except Exception as err: LOGGER.error("Error while updating entity %s: %s", self.name, err) else: - self._state = self._get_car_value( - self._feature_name, self._object_name, self._attrib_name, "error" - ) + self._state = self._get_car_value(self._feature_name, self._object_name, self._attrib_name, "error") self.async_write_ha_state() def _mercedes_me_update(self) -> None: From e9568c8fe0dfdc1fd144d96e989a6881570792c9 Mon Sep 17 00:00:00 2001 From: Philipp Waller <1090452+philippwaller@users.noreply.github.com> Date: Sat, 12 Oct 2024 23:36:18 +0200 Subject: [PATCH 6/9] refactor: improve the setup phase of switches --- custom_components/mbapi2020/switch.py | 98 +++++++++++++-------------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/custom_components/mbapi2020/switch.py b/custom_components/mbapi2020/switch.py index deb68e6e..a7b71240 100644 --- a/custom_components/mbapi2020/switch.py +++ b/custom_components/mbapi2020/switch.py @@ -19,7 +19,12 @@ from . import MercedesMeEntity from .car import Car -from .const import CONF_FT_DISABLE_CAPABILITY_CHECK, DOMAIN, LOGGER, STATE_CONFIRMATION_DURATION +from .const import ( + CONF_FT_DISABLE_CAPABILITY_CHECK, + DOMAIN, + LOGGER, + STATE_CONFIRMATION_DURATION, +) from .coordinator import MBAPI2020DataUpdateCoordinator from .helper import LogHelper as loghelper @@ -29,13 +34,13 @@ class MercedesMeSwitchEntityDescription(SwitchEntityDescription): """Configuration class for MercedesMe switch entities.""" attributes: list[str] | None = None - is_on_fn: Callable[[MercedesMESwitch], Callable[[], Coroutine[Any, Any, bool]]] - turn_on_fn: Callable[[MercedesMESwitch], Callable[[], Coroutine[Any, Any, None]]] - turn_off_fn: Callable[[MercedesMESwitch], Callable[[], Coroutine[Any, Any, None]]] + is_on_fn: Callable[[MercedesMeSwitch], Callable[[], Coroutine[Any, Any, bool]]] + turn_on_fn: Callable[[MercedesMeSwitch], Callable[[], Coroutine[Any, Any, None]]] + turn_off_fn: Callable[[MercedesMeSwitch], Callable[[], Coroutine[Any, Any, None]]] check_capability_fn: Callable[[Car], Callable[[], Coroutine[Any, Any, bool]]] -SWITCH_CONFIGS: list[MercedesMeSwitchEntityDescription] = [ +SWITCH_DESCRIPTIONS: list[MercedesMeSwitchEntityDescription] = [ MercedesMeSwitchEntityDescription( key="precond", translation_key="precond", @@ -43,9 +48,7 @@ class MercedesMeSwitchEntityDescription(SwitchEntityDescription): is_on_fn=lambda self: self._get_car_value("precond", "precondStatus", "value", default_value=False), turn_on_fn=lambda self, **kwargs: self._coordinator.client.preheat_start_universal(self._vin), turn_off_fn=lambda self, **kwargs: self._coordinator.client.preheat_stop(self._vin), - check_capability_fn=lambda car: car.check_capabilities( - ["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"] - ), + check_capability_fn=lambda car: car.check_capabilities(["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"]), ), MercedesMeSwitchEntityDescription( key="auxheat", @@ -59,7 +62,7 @@ class MercedesMeSwitchEntityDescription(SwitchEntityDescription): ] -class MercedesMESwitch(MercedesMeEntity, SwitchEntity, RestoreEntity): +class MercedesMeSwitch(MercedesMeEntity, SwitchEntity, RestoreEntity): """Representation of a Mercedes Me Switch.""" _entity_description: MercedesMeSwitchEntityDescription @@ -106,7 +109,12 @@ async def _async_handle_state_change(self, state: bool, **kwargs) -> None: except Exception as e: # Log the error and reset state if needed - LOGGER.error("Error changing state to %s: %s", "on" if state else "off", str(e)) + LOGGER.error( + "Error changing state to %s for entity '%s': %s", + "on" if state else "off", + self._entity_description.translation_key, + str(e) + ) self._expected_state = None if self._confirmation_handle: self._confirmation_handle() @@ -149,48 +157,38 @@ def assumed_state(self) -> bool: return self._expected_state is not None -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the switch platform for Mercedes ME.""" +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: + """Set up the switch platform for Mercedes Me.""" coordinator: MBAPI2020DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - - if not coordinator.client.cars: - LOGGER.info("No cars found during the switch creation process") - return - - entities: list[MercedesMESwitch] = [] - skip_capability_check = config_entry.options.get(CONF_FT_DISABLE_CAPABILITY_CHECK, False) - - for car in coordinator.client.cars.values(): + skip_capability_check: bool = config_entry.options.get(CONF_FT_DISABLE_CAPABILITY_CHECK, False) + + def check_capability(car: Car, description: MercedesMeSwitchEntityDescription) -> bool: + """Check if the car supports the necessary capability for the given feature description.""" + if not skip_capability_check and not description.check_capability_fn(car): + vin_masked = loghelper.Mask_VIN(car.finorvin) + LOGGER.debug("Skipping feature '%s' for VIN '%s' due to lack of required capability", description.key, vin_masked) + return False + return True + + def create_entity(description: MercedesMeSwitchEntityDescription, car: Car) -> MercedesMeSwitch | None: + """Create a MercedesMeSwitch entity for the car based on the given description.""" vin_masked = loghelper.Mask_VIN(car.finorvin) - - for description in SWITCH_CONFIGS: - if description.check_capability_fn is None: - LOGGER.error("Missing capability check for switch config '%s'. Skipping", description.key) - continue - - if not skip_capability_check and not description.check_capability_fn(car): - LOGGER.debug("Car '%s' does not support feature '%s'. Skipping", vin_masked, description.key) - continue - - try: - entities.append(MercedesMESwitch(description, car.finorvin, coordinator)) - LOGGER.debug( - "Created switch entity for car '%s': Internal Name='%s', Entity Name='%s'", - vin_masked, - description.key, - description.translation_key, - ) - except Exception as e: - LOGGER.error( - "Error creating switch entity '%s' for car '%s': %s", - description.key, - vin_masked, - str(e), - ) + try: + entity = MercedesMeSwitch(description, car.finorvin, coordinator) + LOGGER.debug("Created switch entity for VIN: '%s', feature: '%s'", vin_masked, description.key) + except Exception as e: + LOGGER.error("Error creating switch entity for VIN: '%s', feature: '%s'. Exception:", vin_masked, description.key, exc_info=True) + return None + else: + return entity + + entities: list[MercedesMeSwitch] = [ + entity + for car in coordinator.client.cars.values() # Iterate over all cars + for description in SWITCH_DESCRIPTIONS # Iterate over all feature descriptions + if check_capability(car, description) # Check if the car supports the feature + and (entity := create_entity(description, car)) # Create the entity if possible + ] async_add_entities(entities) From cdfd1efdb4b472173caed26a2415c43ef67d2f75 Mon Sep 17 00:00:00 2001 From: Philipp Waller <1090452+philippwaller@users.noreply.github.com> Date: Sun, 13 Oct 2024 00:29:56 +0200 Subject: [PATCH 7/9] refactor: create new base description class --- custom_components/mbapi2020/__init__.py | 20 ++++++++++++++++---- custom_components/mbapi2020/switch.py | 7 ++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/custom_components/mbapi2020/__init__.py b/custom_components/mbapi2020/__init__.py index e632203f..3522521f 100644 --- a/custom_components/mbapi2020/__init__.py +++ b/custom_components/mbapi2020/__init__.py @@ -3,14 +3,13 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import datetime import time -from typing import Protocol +from typing import Any import aiohttp -import voluptuous as vol - from custom_components.mbapi2020.car import Car, CarAttribute, RcpOptions from custom_components.mbapi2020.const import ( ATTR_MB_MANUFACTURER, @@ -26,10 +25,17 @@ from custom_components.mbapi2020.errors import WebsocketError from custom_components.mbapi2020.helper import LogHelper as loghelper from custom_components.mbapi2020.services import setup_services +import voluptuous as vol + +from homeassistant.components.switch import SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import ConfigType @@ -229,6 +235,12 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): LOGGER.debug("unload result: %s", unload_ok) return unload_ok +@dataclass(frozen=True, kw_only=True) +class MercedesMeEntityDescription(SwitchEntityDescription): + """Configuration class for MercedesMe entities.""" + + attributes: list[str] | None = None + check_capability_fn: Callable[[Car], Callable[[], Coroutine[Any, Any, bool]]] class MercedesMeEntity(CoordinatorEntity[MBAPI2020DataUpdateCoordinator], Entity): """Entity class for MercedesMe devices.""" diff --git a/custom_components/mbapi2020/switch.py b/custom_components/mbapi2020/switch.py index a7b71240..e29e7b2f 100644 --- a/custom_components/mbapi2020/switch.py +++ b/custom_components/mbapi2020/switch.py @@ -17,7 +17,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from . import MercedesMeEntity +from . import MercedesMeEntity, MercedesMeEntityDescription from .car import Car from .const import ( CONF_FT_DISABLE_CAPABILITY_CHECK, @@ -30,15 +30,12 @@ @dataclass(frozen=True, kw_only=True) -class MercedesMeSwitchEntityDescription(SwitchEntityDescription): +class MercedesMeSwitchEntityDescription(MercedesMeEntityDescription, SwitchEntityDescription): """Configuration class for MercedesMe switch entities.""" - attributes: list[str] | None = None is_on_fn: Callable[[MercedesMeSwitch], Callable[[], Coroutine[Any, Any, bool]]] turn_on_fn: Callable[[MercedesMeSwitch], Callable[[], Coroutine[Any, Any, None]]] turn_off_fn: Callable[[MercedesMeSwitch], Callable[[], Coroutine[Any, Any, None]]] - check_capability_fn: Callable[[Car], Callable[[], Coroutine[Any, Any, bool]]] - SWITCH_DESCRIPTIONS: list[MercedesMeSwitchEntityDescription] = [ MercedesMeSwitchEntityDescription( From f89695e576e05239b716d1e4d6f4ce4c7dffb720 Mon Sep 17 00:00:00 2001 From: Rene Nulsch Date: Sun, 13 Oct 2024 12:21:59 +0200 Subject: [PATCH 8/9] remove german translation for the switches --- custom_components/mbapi2020/translations/de.json | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/custom_components/mbapi2020/translations/de.json b/custom_components/mbapi2020/translations/de.json index 05ba0570..e1804e0f 100644 --- a/custom_components/mbapi2020/translations/de.json +++ b/custom_components/mbapi2020/translations/de.json @@ -47,15 +47,5 @@ } } } - }, - "entity": { - "switch": { - "auxheat": { - "name": "Standheizung" - }, - "precond": { - "name": "Vorklimatisierung" - } - } } -} +} \ No newline at end of file From 5820aba5585adf868d0c7d6aac56a9bbb5b2f03b Mon Sep 17 00:00:00 2001 From: Rene Nulsch Date: Sun, 13 Oct 2024 14:03:30 +0200 Subject: [PATCH 9/9] Set entity_description, cleanup, linter --- custom_components/mbapi2020/__init__.py | 66 ++++++++++++------------- custom_components/mbapi2020/switch.py | 37 ++++++++------ 2 files changed, 53 insertions(+), 50 deletions(-) diff --git a/custom_components/mbapi2020/__init__.py b/custom_components/mbapi2020/__init__.py index 3522521f..5529d291 100644 --- a/custom_components/mbapi2020/__init__.py +++ b/custom_components/mbapi2020/__init__.py @@ -10,6 +10,8 @@ from typing import Any import aiohttp +import voluptuous as vol + from custom_components.mbapi2020.car import Car, CarAttribute, RcpOptions from custom_components.mbapi2020.const import ( ATTR_MB_MANUFACTURER, @@ -25,17 +27,11 @@ from custom_components.mbapi2020.errors import WebsocketError from custom_components.mbapi2020.helper import LogHelper as loghelper from custom_components.mbapi2020.services import setup_services -import voluptuous as vol - from homeassistant.components.switch import SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import ConfigType @@ -113,7 +109,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): try: capabilities = await coordinator.client.webapi.get_car_capabilities_commands(vin) hass.async_add_executor_job( - coordinator.client.write_debug_json_output, capabilities, f"ca-{loghelper.Mask_VIN(vin)}-", True + coordinator.client.write_debug_json_output, + capabilities, + f"ca-{loghelper.Mask_VIN(vin)}-", + True, ) if capabilities: for feature in capabilities.get("commands"): @@ -139,7 +138,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): rcp_supported_settings = await coordinator.client.webapi.get_car_rcp_supported_settings(vin) if rcp_supported_settings: hass.async_add_executor_job( - coordinator.client.write_debug_json_output, rcp_supported_settings, "rcs" + coordinator.client.write_debug_json_output, + rcp_supported_settings, + "rcs", ) if rcp_supported_settings.get("data"): if rcp_supported_settings.get("data").get("attributes"): @@ -164,7 +165,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): setting_result = await coordinator.client.webapi.get_car_rcp_settings(vin, setting) if setting_result is not None: hass.async_add_executor_job( - coordinator.client.write_debug_json_output, setting_result, f"rcs_{setting}" + coordinator.client.write_debug_json_output, + setting_result, + f"rcs_{setting}", ) current_car = Car(vin) @@ -235,6 +238,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): LOGGER.debug("unload result: %s", unload_ok) return unload_ok + @dataclass(frozen=True, kw_only=True) class MercedesMeEntityDescription(SwitchEntityDescription): """Configuration class for MercedesMe entities.""" @@ -242,6 +246,7 @@ class MercedesMeEntityDescription(SwitchEntityDescription): attributes: list[str] | None = None check_capability_fn: Callable[[Car], Callable[[], Coroutine[Any, Any, bool]]] + class MercedesMeEntity(CoordinatorEntity[MBAPI2020DataUpdateCoordinator], Entity): """Entity class for MercedesMe devices.""" @@ -256,32 +261,25 @@ def __init__( should_poll: bool = False, ) -> None: """Initialize the MercedesMe entity.""" - super().__init__(coordinator) self._hass = coordinator.hass self._coordinator = coordinator self._vin = vin self._internal_name = internal_name self._sensor_config = config + self._car = self._coordinator.client.cars[self._vin] + self._feature_name = None + self._object_name = None + self._attrib_name = None + self._flip_result = False self._state = None # Temporary workaround: If PR get's approved, all entity types should be migrated to the new config classes if isinstance(config, EntityDescription): - self._sensor_name = config.translation_key - self._internal_unit = None - self._feature_name = None - self._object_name = None - self._attrib_name = None - self._flip_result = False - self._attr_device_class = config.device_class - self._attr_icon = config.icon - self._attr_state_class = None - self._attr_entity_category = config.entity_category self._attributes = config.attributes + self.entity_description = config else: - self._sensor_name = config[scf.DISPLAY_NAME.value] - self._internal_unit = config[scf.UNIT_OF_MEASUREMENT.value] self._feature_name = config[scf.OBJECT_NAME.value] self._object_name = config[scf.ATTRIBUTE_NAME.value] self._attrib_name = config[scf.VALUE_FIELD_NAME.value] @@ -291,25 +289,23 @@ def __init__( self._attr_state_class = self._sensor_config[scf.STATE_CLASS.value] self._attr_entity_category = self._sensor_config[scf.ENTITY_CATEGORY.value] self._attributes = self._sensor_config[scf.EXTENDED_ATTRIBUTE_LIST.value] - - self._car = self._coordinator.client.cars[self._vin] - self._use_chinese_location_data: bool = self._coordinator.config_entry.options.get( - CONF_ENABLE_CHINA_GCJ_02, False - ) - - self._name = f"{self._car.licenseplate} {self._sensor_name}" + self._attr_native_unit_of_measurement = self.unit_of_measurement + self._use_chinese_location_data: bool = self._coordinator.config_entry.options.get( + CONF_ENABLE_CHINA_GCJ_02, False + ) + self._attr_translation_key = self._internal_name.lower() + self._attr_name = config[scf.DISPLAY_NAME.value] + self._name = f"{self._car.licenseplate} {config[scf.DISPLAY_NAME.value]}" self._attr_device_info = {"identifiers": {(DOMAIN, self._vin)}} self._attr_should_poll = should_poll - - self._attr_native_unit_of_measurement = self.unit_of_measurement - self._attr_translation_key = self._internal_name.lower() self._attr_unique_id = slugify(f"{self._vin}_{self._internal_name}") - self._attr_name = self._sensor_name + + super().__init__(coordinator) def device_retrieval_status(self): """Return the retrieval_status of the sensor.""" - if self._sensor_name == "Car": + if self._internal_name == "car": return "VALID" return self._get_car_value(self._feature_name, self._object_name, "retrievalstatus", "error") diff --git a/custom_components/mbapi2020/switch.py b/custom_components/mbapi2020/switch.py index e29e7b2f..27cfe6d4 100644 --- a/custom_components/mbapi2020/switch.py +++ b/custom_components/mbapi2020/switch.py @@ -19,12 +19,7 @@ from . import MercedesMeEntity, MercedesMeEntityDescription from .car import Car -from .const import ( - CONF_FT_DISABLE_CAPABILITY_CHECK, - DOMAIN, - LOGGER, - STATE_CONFIRMATION_DURATION, -) +from .const import CONF_FT_DISABLE_CAPABILITY_CHECK, DOMAIN, LOGGER, STATE_CONFIRMATION_DURATION from .coordinator import MBAPI2020DataUpdateCoordinator from .helper import LogHelper as loghelper @@ -37,6 +32,7 @@ class MercedesMeSwitchEntityDescription(MercedesMeEntityDescription, SwitchEntit turn_on_fn: Callable[[MercedesMeSwitch], Callable[[], Coroutine[Any, Any, None]]] turn_off_fn: Callable[[MercedesMeSwitch], Callable[[], Coroutine[Any, Any, None]]] + SWITCH_DESCRIPTIONS: list[MercedesMeSwitchEntityDescription] = [ MercedesMeSwitchEntityDescription( key="precond", @@ -45,7 +41,9 @@ class MercedesMeSwitchEntityDescription(MercedesMeEntityDescription, SwitchEntit is_on_fn=lambda self: self._get_car_value("precond", "precondStatus", "value", default_value=False), turn_on_fn=lambda self, **kwargs: self._coordinator.client.preheat_start_universal(self._vin), turn_off_fn=lambda self, **kwargs: self._coordinator.client.preheat_stop(self._vin), - check_capability_fn=lambda car: car.check_capabilities(["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"]), + check_capability_fn=lambda car: car.check_capabilities( + ["ZEV_PRECONDITIONING_START", "ZEV_PRECONDITIONING_STOP"] + ), ), MercedesMeSwitchEntityDescription( key="auxheat", @@ -110,7 +108,7 @@ async def _async_handle_state_change(self, state: bool, **kwargs) -> None: "Error changing state to %s for entity '%s': %s", "on" if state else "off", self._entity_description.translation_key, - str(e) + str(e), ) self._expected_state = None if self._confirmation_handle: @@ -154,7 +152,9 @@ def assumed_state(self) -> bool: return self._expected_state is not None -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the switch platform for Mercedes Me.""" coordinator: MBAPI2020DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] @@ -164,7 +164,9 @@ def check_capability(car: Car, description: MercedesMeSwitchEntityDescription) - """Check if the car supports the necessary capability for the given feature description.""" if not skip_capability_check and not description.check_capability_fn(car): vin_masked = loghelper.Mask_VIN(car.finorvin) - LOGGER.debug("Skipping feature '%s' for VIN '%s' due to lack of required capability", description.key, vin_masked) + LOGGER.debug( + "Skipping feature '%s' for VIN '%s' due to lack of required capability", description.key, vin_masked + ) return False return True @@ -175,17 +177,22 @@ def create_entity(description: MercedesMeSwitchEntityDescription, car: Car) -> M entity = MercedesMeSwitch(description, car.finorvin, coordinator) LOGGER.debug("Created switch entity for VIN: '%s', feature: '%s'", vin_masked, description.key) except Exception as e: - LOGGER.error("Error creating switch entity for VIN: '%s', feature: '%s'. Exception:", vin_masked, description.key, exc_info=True) + LOGGER.error( + "Error creating switch entity for VIN: '%s', feature: '%s'. Exception:", + vin_masked, + description.key, + exc_info=True, + ) return None else: return entity entities: list[MercedesMeSwitch] = [ entity - for car in coordinator.client.cars.values() # Iterate over all cars - for description in SWITCH_DESCRIPTIONS # Iterate over all feature descriptions - if check_capability(car, description) # Check if the car supports the feature - and (entity := create_entity(description, car)) # Create the entity if possible + for car in coordinator.client.cars.values() # Iterate over all cars + for description in SWITCH_DESCRIPTIONS # Iterate over all feature descriptions + if check_capability(car, description) # Check if the car supports the feature + and (entity := create_entity(description, car)) # Create the entity if possible ] async_add_entities(entities)