├── .github ├── release-drafter.yml └── workflows │ ├── release-drafter.yml │ └── reset_to_ha.yml ├── .gitignore ├── README.md ├── create_issue.patch ├── custom_components ├── .DS_Store └── bmw_connected_drive │ ├── __init__.py │ ├── binary_sensor.py │ ├── button.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── device_tracker.py │ ├── diagnostics.py │ ├── entity.py │ ├── icons.json │ ├── lock.py │ ├── manifest.json │ ├── notify.py │ ├── number.py │ ├── select.py │ ├── sensor.py │ ├── strings.json │ └── switch.py ├── hacs.json └── pictures ├── example_1.jpg ├── example_2.jpg └── example_3.jpg /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What's changed -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - master 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Generate CalVer version 14 | id: calver 15 | shell: python 16 | run: | 17 | import json 18 | import urllib.request 19 | from datetime import date 20 | 21 | dat = date.today().strftime("%Y%m%d") 22 | req_rel = urllib.request.Request( 23 | "https://api.github.com/repos/bimmerconnected/ha_custom_component/releases/latest", 24 | headers={"Accept": "application/vnd.github+json"} 25 | ) 26 | with urllib.request.urlopen(req_rel) as f: 27 | rel = json.loads(f.read()) 28 | 29 | major, minor = rel["tag_name"].split(".") 30 | 31 | if major == dat: 32 | minor = int(minor) + 1 33 | else: 34 | major = dat 35 | minor = 1 36 | 37 | version = f"{major}.{minor}" 38 | print(f"::set-output name=version::{version}") 39 | print(f"Version set to {version}") 40 | 41 | 42 | req_com = urllib.request.Request( 43 | f"https://api.github.com/repos/bimmerconnected/ha_custom_component/commits?since={rel['created_at']}", 44 | headers={"Accept": "application/vnd.github+json"} 45 | ) 46 | with urllib.request.urlopen(req_com) as f: 47 | com = json.loads(f.read()) 48 | 49 | changes = "%0A".join(["* {} ({})".format(next(iter(c["commit"]["message"].split("\n"))), c["sha"]) for c in com[:-1]]) 50 | print(f"::set-output name=changes::{changes}") 51 | print(f"Changes set to:\n{changes}") 52 | # Drafts your next Release notes as Pull Requests are merged into "master" 53 | - uses: release-drafter/release-drafter@v5.20.0 54 | with: 55 | tag: ${{ steps.calver.outputs.version }} 56 | name: ${{ steps.calver.outputs.version }} 57 | version: ${{ steps.calver.outputs.version }} 58 | footer: ${{ steps.calver.outputs.changes }} 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | -------------------------------------------------------------------------------- /.github/workflows/reset_to_ha.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Refresh from Home Assistant 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 11 | jobs: 12 | # This workflow contains a single job called "build" 13 | build: 14 | # The type of runner that the job will run on 15 | runs-on: ubuntu-latest 16 | 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v2 21 | 22 | # Runs a set of commands using the runners shell 23 | - name: Clone Home Assistant & get latest tag 24 | run: | 25 | git clone https://github.com/home-assistant/core.git ${{ runner.temp }}/core 26 | cd ${{ runner.temp }}/core 27 | echo "HA_VERSION=$(git describe --tags $(git rev-list --tags --max-count=1))" >> $GITHUB_ENV 28 | 29 | - name: Checkout latest HA release tag 30 | run: | 31 | cd ${{ runner.temp }}/core 32 | git checkout tags/${{ env.HA_VERSION }} 33 | 34 | - name: Copy component from HA into workspace 35 | run: | 36 | rsync -av --delete \ 37 | ${{ runner.temp }}/core/homeassistant/components/bmw_connected_drive/ \ 38 | ${{ github.workspace }}/custom_components/bmw_connected_drive 39 | 40 | - name: Update version number 41 | run: | 42 | cd ${{ github.workspace }}/custom_components/bmw_connected_drive 43 | sed -i "$(($(wc -l < manifest.json) - 1))i\\ \"version\": \"${{ env.HA_VERSION }}\",\\" manifest.json 44 | 45 | - name: Change name in manifest 46 | run: | 47 | cd ${{ github.workspace }}/custom_components/bmw_connected_drive 48 | sed -i 's/"name": "\(.*\)"/"name": "\1 BETA"/' manifest.json 49 | 50 | - name: Create PR with new changes 51 | uses: peter-evans/create-pull-request@v3 52 | with: 53 | commit-message: Update to Home Assistant ${{ env.HA_VERSION }} 54 | branch: core/${{ env.HA_VERSION }} 55 | delete-branch: true 56 | title: Update to Home Assistant ${{ env.HA_VERSION }} 57 | 58 | 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/* 2 | config2/* 3 | 4 | tests/testing_config/deps 5 | tests/testing_config/home-assistant.log 6 | 7 | # hass-release 8 | data/ 9 | .token 10 | 11 | # Hide sublime text stuff 12 | *.sublime-project 13 | *.sublime-workspace 14 | 15 | # Hide some OS X stuff 16 | .DS_Store 17 | .AppleDouble 18 | .LSOverride 19 | Icon 20 | 21 | # Thumbnails 22 | ._* 23 | 24 | # IntelliJ IDEA 25 | .idea 26 | *.iml 27 | 28 | # pytest 29 | .pytest_cache 30 | .cache 31 | 32 | # GITHUB Proposed Python stuff: 33 | *.py[cod] 34 | 35 | # C extensions 36 | *.so 37 | 38 | # Packages 39 | *.egg 40 | *.egg-info 41 | dist 42 | build 43 | eggs 44 | .eggs 45 | parts 46 | bin 47 | var 48 | sdist 49 | develop-eggs 50 | .installed.cfg 51 | lib 52 | lib64 53 | pip-wheel-metadata 54 | 55 | # Logs 56 | *.log 57 | pip-log.txt 58 | 59 | # Unit test / coverage reports 60 | .coverage 61 | .tox 62 | coverage.xml 63 | nosetests.xml 64 | htmlcov/ 65 | test-reports/ 66 | test-results.xml 67 | test-output.xml 68 | 69 | # Translations 70 | *.mo 71 | 72 | # Mr Developer 73 | .mr.developer.cfg 74 | .project 75 | .pydevproject 76 | 77 | .python-version 78 | 79 | # emacs auto backups 80 | *~ 81 | *# 82 | *.orig 83 | 84 | # venv stuff 85 | pyvenv.cfg 86 | pip-selfcheck.json 87 | venv 88 | .venv 89 | Pipfile* 90 | share/* 91 | Scripts/ 92 | 93 | # vimmy stuff 94 | *.swp 95 | *.swo 96 | tags 97 | ctags.tmp 98 | 99 | # vagrant stuff 100 | virtualization/vagrant/setup_done 101 | virtualization/vagrant/.vagrant 102 | virtualization/vagrant/config 103 | 104 | # Visual Studio Code 105 | .vscode/* 106 | !.vscode/cSpell.json 107 | !.vscode/extensions.json 108 | !.vscode/tasks.json 109 | 110 | # Built docs 111 | docs/build 112 | 113 | # Windows Explorer 114 | desktop.ini 115 | /home-assistant.pyproj 116 | /home-assistant.sln 117 | /.vs/* 118 | 119 | # mypy 120 | /.mypy_cache/* 121 | /.dmypy.json 122 | 123 | # Secrets 124 | .lokalise_token 125 | 126 | # monkeytype 127 | monkeytype.sqlite3 128 | 129 | # This is left behind by Azure Restore Cache 130 | tmp_cache 131 | 132 | # python-language-server / Rope 133 | .ropeproject 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BMW Connected Drive Custom Component for Development 2 | Home Assistant Custom Component of BMW Connected Drive **for development purposes only**! 3 | For details please see [the official component documentation](https://www.home-assistant.io/integrations/bmw_connected_drive/) or [the bimmer_connected library](https://github.com/bimmerconnected/bimmer_connected). 4 | 5 | ## Installation (HACS) 6 | When using HACS, just add this repository as a [custom repostiory](https://hacs.xyz/docs/navigation/settings#custom-repositories) of category `Integration` with the url `https://github.com/bimmerconnected/ha_custom_component`. 7 | 8 | ## Installation (manual) 9 | Place the folder `bmw_connected_drive` and all it's files in the folder `custom_components` in the config folder of HA (where configuration.yaml is). 10 | 11 | # Release notes 12 | See [Github Releases](https://github.com/bimmerconnected/ha_custom_component/releases/). 13 | 14 | # Version 15 | Can be used with Home Assistant >= `2022.2` 16 | -------------------------------------------------------------------------------- /create_issue.patch: -------------------------------------------------------------------------------- 1 | diff --git a/custom_components/bmw_connected_drive/__init__.py b/custom_components/bmw_connected_drive/__init__.py 2 | index 9e43cfc..9921e3f 100644 3 | --- a/custom_components/bmw_connected_drive/__init__.py 4 | +++ b/custom_components/bmw_connected_drive/__init__.py 5 | @@ -14,6 +14,7 @@ from homeassistant.helpers import ( 6 | device_registry as dr, 7 | discovery, 8 | entity_registry as er, 9 | + issue_registry as ir, 10 | ) 11 | import homeassistant.helpers.config_validation as cv 12 | 13 | @@ -130,6 +131,16 @@ async def _async_migrate_entries( 14 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 15 | """Set up BMW Connected Drive from a config entry.""" 16 | 17 | + ir.async_create_issue( 18 | + hass, 19 | + DOMAIN, 20 | + "stop_using_custom_component", 21 | + is_fixable=False, 22 | + severity=ir.IssueSeverity.ERROR, 23 | + translation_key="stop_using_custom_component", 24 | + ) 25 | + 26 | _async_migrate_options_from_data_if_missing(hass, entry) 27 | 28 | await _async_migrate_entries(hass, entry) 29 | diff --git a/custom_components/bmw_connected_drive/strings.json b/custom_components/bmw_connected_drive/strings.json 30 | index 8078971..65be86f 100644 31 | --- a/custom_components/bmw_connected_drive/strings.json 32 | +++ b/custom_components/bmw_connected_drive/strings.json 33 | @@ -215,5 +215,11 @@ 34 | "missing_captcha": { 35 | "message": "Login requires captcha validation" 36 | } 37 | + }, 38 | + "issues": { 39 | + "stop_using_custom_component": { 40 | + "title": "Stop using custom component", 41 | + "description": "The custom component for BMW Connected Drive is outdated. Please remove the custom component and use the version shipped with Home Assistant." 42 | + } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /custom_components/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bimmerconnected/ha_custom_component/adfb34cf88b38121f6ec61a57641dabe66309547/custom_components/.DS_Store -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/__init__.py: -------------------------------------------------------------------------------- 1 | """Reads vehicle status from MyBMW portal.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform 12 | from homeassistant.core import HomeAssistant, callback 13 | from homeassistant.helpers import ( 14 | device_registry as dr, 15 | discovery, 16 | entity_registry as er, 17 | issue_registry as ir, 18 | ) 19 | import homeassistant.helpers.config_validation as cv 20 | 21 | from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN 22 | from .coordinator import BMWDataUpdateCoordinator 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | SERVICE_SCHEMA = vol.Schema( 28 | vol.Any( 29 | {vol.Required(ATTR_VIN): cv.string}, 30 | {vol.Required(CONF_DEVICE_ID): cv.string}, 31 | ) 32 | ) 33 | 34 | DEFAULT_OPTIONS = { 35 | CONF_READ_ONLY: False, 36 | } 37 | 38 | PLATFORMS = [ 39 | Platform.BINARY_SENSOR, 40 | Platform.BUTTON, 41 | Platform.DEVICE_TRACKER, 42 | Platform.LOCK, 43 | Platform.NOTIFY, 44 | Platform.NUMBER, 45 | Platform.SELECT, 46 | Platform.SENSOR, 47 | Platform.SWITCH, 48 | ] 49 | 50 | SERVICE_UPDATE_STATE = "update_state" 51 | 52 | 53 | type BMWConfigEntry = ConfigEntry[BMWData] 54 | 55 | 56 | @dataclass 57 | class BMWData: 58 | """Class to store BMW runtime data.""" 59 | 60 | coordinator: BMWDataUpdateCoordinator 61 | 62 | 63 | @callback 64 | def _async_migrate_options_from_data_if_missing( 65 | hass: HomeAssistant, entry: ConfigEntry 66 | ) -> None: 67 | data = dict(entry.data) 68 | options = dict(entry.options) 69 | 70 | if CONF_READ_ONLY in data or list(options) != list(DEFAULT_OPTIONS): 71 | options = dict( 72 | DEFAULT_OPTIONS, 73 | **{k: v for k, v in options.items() if k in DEFAULT_OPTIONS}, 74 | ) 75 | options[CONF_READ_ONLY] = data.pop(CONF_READ_ONLY, False) 76 | 77 | hass.config_entries.async_update_entry(entry, data=data, options=options) 78 | 79 | 80 | async def _async_migrate_entries( 81 | hass: HomeAssistant, config_entry: BMWConfigEntry 82 | ) -> bool: 83 | """Migrate old entry.""" 84 | entity_registry = er.async_get(hass) 85 | 86 | @callback 87 | def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: 88 | replacements = { 89 | "charging_level_hv": "fuel_and_battery.remaining_battery_percent", 90 | "fuel_percent": "fuel_and_battery.remaining_fuel_percent", 91 | "ac_current_limit": "charging_profile.ac_current_limit", 92 | "charging_start_time": "fuel_and_battery.charging_start_time", 93 | "charging_end_time": "fuel_and_battery.charging_end_time", 94 | "charging_status": "fuel_and_battery.charging_status", 95 | "charging_target": "fuel_and_battery.charging_target", 96 | "remaining_battery_percent": "fuel_and_battery.remaining_battery_percent", 97 | "remaining_range_total": "fuel_and_battery.remaining_range_total", 98 | "remaining_range_electric": "fuel_and_battery.remaining_range_electric", 99 | "remaining_range_fuel": "fuel_and_battery.remaining_range_fuel", 100 | "remaining_fuel": "fuel_and_battery.remaining_fuel", 101 | "remaining_fuel_percent": "fuel_and_battery.remaining_fuel_percent", 102 | "activity": "climate.activity", 103 | } 104 | if (key := entry.unique_id.split("-")[-1]) in replacements: 105 | new_unique_id = entry.unique_id.replace(key, replacements[key]) 106 | _LOGGER.debug( 107 | "Migrating entity '%s' unique_id from '%s' to '%s'", 108 | entry.entity_id, 109 | entry.unique_id, 110 | new_unique_id, 111 | ) 112 | if existing_entity_id := entity_registry.async_get_entity_id( 113 | entry.domain, entry.platform, new_unique_id 114 | ): 115 | _LOGGER.debug( 116 | "Cannot migrate to unique_id '%s', already exists for '%s'", 117 | new_unique_id, 118 | existing_entity_id, 119 | ) 120 | return None 121 | return { 122 | "new_unique_id": new_unique_id, 123 | } 124 | return None 125 | 126 | await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) 127 | 128 | return True 129 | 130 | 131 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 132 | """Set up BMW Connected Drive from a config entry.""" 133 | 134 | ir.async_create_issue( 135 | hass, 136 | DOMAIN, 137 | "stop_using_custom_component", 138 | is_fixable=False, 139 | severity=ir.IssueSeverity.ERROR, 140 | translation_key="stop_using_custom_component", 141 | ) 142 | 143 | _async_migrate_options_from_data_if_missing(hass, entry) 144 | 145 | await _async_migrate_entries(hass, entry) 146 | 147 | # Set up one data coordinator per account/config entry 148 | coordinator = BMWDataUpdateCoordinator( 149 | hass, 150 | entry=entry, 151 | ) 152 | await coordinator.async_config_entry_first_refresh() 153 | 154 | entry.runtime_data = BMWData(coordinator) 155 | 156 | # Set up all platforms except notify 157 | await hass.config_entries.async_forward_entry_setups( 158 | entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] 159 | ) 160 | 161 | # set up notify platform, no entry support for notify platform yet, 162 | # have to use discovery to load platform. 163 | hass.async_create_task( 164 | discovery.async_load_platform( 165 | hass, 166 | Platform.NOTIFY, 167 | DOMAIN, 168 | {CONF_NAME: DOMAIN, CONF_ENTITY_ID: entry.entry_id}, 169 | {}, 170 | ) 171 | ) 172 | 173 | # Clean up vehicles which are not assigned to the account anymore 174 | account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles} 175 | device_registry = dr.async_get(hass) 176 | device_entries = dr.async_entries_for_config_entry( 177 | device_registry, config_entry_id=entry.entry_id 178 | ) 179 | for device in device_entries: 180 | if not device.identifiers.intersection(account_vehicles): 181 | device_registry.async_update_device( 182 | device.id, remove_config_entry_id=entry.entry_id 183 | ) 184 | 185 | return True 186 | 187 | 188 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 189 | """Unload a config entry.""" 190 | 191 | return await hass.config_entries.async_unload_platforms( 192 | entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] 193 | ) 194 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Reads vehicle status from BMW MyBMW portal.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable 6 | from dataclasses import dataclass 7 | import logging 8 | from typing import Any 9 | 10 | from bimmer_connected.vehicle import MyBMWVehicle 11 | from bimmer_connected.vehicle.doors_windows import LockState 12 | from bimmer_connected.vehicle.fuel_and_battery import ChargingState 13 | from bimmer_connected.vehicle.reports import ConditionBasedService 14 | 15 | from homeassistant.components.binary_sensor import ( 16 | BinarySensorDeviceClass, 17 | BinarySensorEntity, 18 | BinarySensorEntityDescription, 19 | ) 20 | from homeassistant.core import HomeAssistant, callback 21 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 22 | from homeassistant.util.unit_system import UnitSystem 23 | 24 | from . import BMWConfigEntry 25 | from .const import UNIT_MAP 26 | from .coordinator import BMWDataUpdateCoordinator 27 | from .entity import BMWBaseEntity 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | ALLOWED_CONDITION_BASED_SERVICE_KEYS = { 33 | "BRAKE_FLUID", 34 | "BRAKE_PADS_FRONT", 35 | "BRAKE_PADS_REAR", 36 | "EMISSION_CHECK", 37 | "ENGINE_OIL", 38 | "OIL", 39 | "TIRE_WEAR_FRONT", 40 | "TIRE_WEAR_REAR", 41 | "VEHICLE_CHECK", 42 | "VEHICLE_TUV", 43 | } 44 | LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set() 45 | 46 | ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = { 47 | "ENGINE_OIL", 48 | "TIRE_PRESSURE", 49 | "WASHING_FLUID", 50 | } 51 | LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS: set[str] = set() 52 | 53 | 54 | def _condition_based_services( 55 | vehicle: MyBMWVehicle, unit_system: UnitSystem 56 | ) -> dict[str, Any]: 57 | extra_attributes = {} 58 | for report in vehicle.condition_based_services.messages: 59 | if ( 60 | report.service_type not in ALLOWED_CONDITION_BASED_SERVICE_KEYS 61 | and report.service_type not in LOGGED_CONDITION_BASED_SERVICE_WARNINGS 62 | ): 63 | _LOGGER.warning( 64 | "'%s' not an allowed condition based service (%s)", 65 | report.service_type, 66 | report, 67 | ) 68 | LOGGED_CONDITION_BASED_SERVICE_WARNINGS.add(report.service_type) 69 | continue 70 | 71 | extra_attributes.update(_format_cbs_report(report, unit_system)) 72 | return extra_attributes 73 | 74 | 75 | def _check_control_messages(vehicle: MyBMWVehicle) -> dict[str, Any]: 76 | extra_attributes: dict[str, Any] = {} 77 | for message in vehicle.check_control_messages.messages: 78 | if ( 79 | message.description_short not in ALLOWED_CHECK_CONTROL_MESSAGE_KEYS 80 | and message.description_short not in LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS 81 | ): 82 | _LOGGER.warning( 83 | "'%s' not an allowed check control message (%s)", 84 | message.description_short, 85 | message, 86 | ) 87 | LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS.add(message.description_short) 88 | continue 89 | 90 | extra_attributes[message.description_short.lower()] = message.state.value 91 | return extra_attributes 92 | 93 | 94 | def _format_cbs_report( 95 | report: ConditionBasedService, unit_system: UnitSystem 96 | ) -> dict[str, Any]: 97 | result: dict[str, Any] = {} 98 | service_type = report.service_type.lower() 99 | result[service_type] = report.state.value 100 | if report.due_date is not None: 101 | result[f"{service_type}_date"] = report.due_date.strftime("%Y-%m-%d") 102 | if report.due_distance.value and report.due_distance.unit: 103 | distance = round( 104 | unit_system.length( 105 | report.due_distance.value, 106 | UNIT_MAP.get(report.due_distance.unit, report.due_distance.unit), 107 | ) 108 | ) 109 | result[f"{service_type}_distance"] = f"{distance} {unit_system.length_unit}" 110 | return result 111 | 112 | 113 | @dataclass(frozen=True, kw_only=True) 114 | class BMWBinarySensorEntityDescription(BinarySensorEntityDescription): 115 | """Describes BMW binary_sensor entity.""" 116 | 117 | value_fn: Callable[[MyBMWVehicle], bool] 118 | attr_fn: Callable[[MyBMWVehicle, UnitSystem], dict[str, Any]] | None = None 119 | is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled 120 | 121 | 122 | SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( 123 | BMWBinarySensorEntityDescription( 124 | key="lids", 125 | translation_key="lids", 126 | device_class=BinarySensorDeviceClass.OPENING, 127 | # device class opening: On means open, Off means closed 128 | value_fn=lambda v: not v.doors_and_windows.all_lids_closed, 129 | attr_fn=lambda v, u: { 130 | lid.name: lid.state.value for lid in v.doors_and_windows.lids 131 | }, 132 | ), 133 | BMWBinarySensorEntityDescription( 134 | key="windows", 135 | translation_key="windows", 136 | device_class=BinarySensorDeviceClass.OPENING, 137 | # device class opening: On means open, Off means closed 138 | value_fn=lambda v: not v.doors_and_windows.all_windows_closed, 139 | attr_fn=lambda v, u: { 140 | window.name: window.state.value for window in v.doors_and_windows.windows 141 | }, 142 | ), 143 | BMWBinarySensorEntityDescription( 144 | key="door_lock_state", 145 | translation_key="door_lock_state", 146 | device_class=BinarySensorDeviceClass.LOCK, 147 | # device class lock: On means unlocked, Off means locked 148 | # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED 149 | value_fn=lambda v: v.doors_and_windows.door_lock_state 150 | not in {LockState.LOCKED, LockState.SECURED}, 151 | attr_fn=lambda v, u: { 152 | "door_lock_state": v.doors_and_windows.door_lock_state.value 153 | }, 154 | ), 155 | BMWBinarySensorEntityDescription( 156 | key="condition_based_services", 157 | translation_key="condition_based_services", 158 | device_class=BinarySensorDeviceClass.PROBLEM, 159 | # device class problem: On means problem detected, Off means no problem 160 | value_fn=lambda v: v.condition_based_services.is_service_required, 161 | attr_fn=_condition_based_services, 162 | ), 163 | BMWBinarySensorEntityDescription( 164 | key="check_control_messages", 165 | translation_key="check_control_messages", 166 | device_class=BinarySensorDeviceClass.PROBLEM, 167 | # device class problem: On means problem detected, Off means no problem 168 | value_fn=lambda v: v.check_control_messages.has_check_control_messages, 169 | attr_fn=lambda v, u: _check_control_messages(v), 170 | ), 171 | # electric 172 | BMWBinarySensorEntityDescription( 173 | key="charging_status", 174 | translation_key="charging_status", 175 | device_class=BinarySensorDeviceClass.BATTERY_CHARGING, 176 | # device class power: On means power detected, Off means no power 177 | value_fn=lambda v: v.fuel_and_battery.charging_status == ChargingState.CHARGING, 178 | is_available=lambda v: v.has_electric_drivetrain, 179 | ), 180 | BMWBinarySensorEntityDescription( 181 | key="connection_status", 182 | translation_key="connection_status", 183 | device_class=BinarySensorDeviceClass.PLUG, 184 | value_fn=lambda v: v.fuel_and_battery.is_charger_connected, 185 | is_available=lambda v: v.has_electric_drivetrain, 186 | ), 187 | BMWBinarySensorEntityDescription( 188 | key="is_pre_entry_climatization_enabled", 189 | translation_key="is_pre_entry_climatization_enabled", 190 | value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled 191 | if v.charging_profile 192 | else False, 193 | is_available=lambda v: v.has_electric_drivetrain, 194 | ), 195 | ) 196 | 197 | 198 | async def async_setup_entry( 199 | hass: HomeAssistant, 200 | config_entry: BMWConfigEntry, 201 | async_add_entities: AddEntitiesCallback, 202 | ) -> None: 203 | """Set up the BMW binary sensors from config entry.""" 204 | coordinator = config_entry.runtime_data.coordinator 205 | 206 | entities = [ 207 | BMWBinarySensor(coordinator, vehicle, description, hass.config.units) 208 | for vehicle in coordinator.account.vehicles 209 | for description in SENSOR_TYPES 210 | if description.is_available(vehicle) 211 | ] 212 | async_add_entities(entities) 213 | 214 | 215 | class BMWBinarySensor(BMWBaseEntity, BinarySensorEntity): 216 | """Representation of a BMW vehicle binary sensor.""" 217 | 218 | entity_description: BMWBinarySensorEntityDescription 219 | 220 | def __init__( 221 | self, 222 | coordinator: BMWDataUpdateCoordinator, 223 | vehicle: MyBMWVehicle, 224 | description: BMWBinarySensorEntityDescription, 225 | unit_system: UnitSystem, 226 | ) -> None: 227 | """Initialize sensor.""" 228 | super().__init__(coordinator, vehicle) 229 | self.entity_description = description 230 | self._unit_system = unit_system 231 | self._attr_unique_id = f"{vehicle.vin}-{description.key}" 232 | 233 | @callback 234 | def _handle_coordinator_update(self) -> None: 235 | """Handle updated data from the coordinator.""" 236 | _LOGGER.debug( 237 | "Updating binary sensor '%s' of %s", 238 | self.entity_description.key, 239 | self.vehicle.name, 240 | ) 241 | self._attr_is_on = self.entity_description.value_fn(self.vehicle) 242 | 243 | if self.entity_description.attr_fn: 244 | self._attr_extra_state_attributes = self.entity_description.attr_fn( 245 | self.vehicle, self._unit_system 246 | ) 247 | 248 | super()._handle_coordinator_update() 249 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/button.py: -------------------------------------------------------------------------------- 1 | """Support for MyBMW button entities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable, Coroutine 6 | from dataclasses import dataclass 7 | import logging 8 | from typing import TYPE_CHECKING, Any 9 | 10 | from bimmer_connected.models import MyBMWAPIError 11 | from bimmer_connected.vehicle import MyBMWVehicle 12 | from bimmer_connected.vehicle.remote_services import RemoteServiceStatus 13 | 14 | from homeassistant.components.button import ButtonEntity, ButtonEntityDescription 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.exceptions import HomeAssistantError 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | 19 | from . import BMWConfigEntry 20 | from .entity import BMWBaseEntity 21 | 22 | if TYPE_CHECKING: 23 | from .coordinator import BMWDataUpdateCoordinator 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | @dataclass(frozen=True, kw_only=True) 29 | class BMWButtonEntityDescription(ButtonEntityDescription): 30 | """Class describing BMW button entities.""" 31 | 32 | remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]] 33 | enabled_when_read_only: bool = False 34 | is_available: Callable[[MyBMWVehicle], bool] = lambda _: True 35 | 36 | 37 | BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( 38 | BMWButtonEntityDescription( 39 | key="light_flash", 40 | translation_key="light_flash", 41 | remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_light_flash(), 42 | ), 43 | BMWButtonEntityDescription( 44 | key="sound_horn", 45 | translation_key="sound_horn", 46 | remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(), 47 | ), 48 | BMWButtonEntityDescription( 49 | key="activate_air_conditioning", 50 | translation_key="activate_air_conditioning", 51 | remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), 52 | ), 53 | BMWButtonEntityDescription( 54 | key="deactivate_air_conditioning", 55 | translation_key="deactivate_air_conditioning", 56 | name="Deactivate air conditioning", 57 | remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), 58 | is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled, 59 | ), 60 | BMWButtonEntityDescription( 61 | key="find_vehicle", 62 | translation_key="find_vehicle", 63 | remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(), 64 | ), 65 | ) 66 | 67 | 68 | async def async_setup_entry( 69 | hass: HomeAssistant, 70 | config_entry: BMWConfigEntry, 71 | async_add_entities: AddEntitiesCallback, 72 | ) -> None: 73 | """Set up the BMW buttons from config entry.""" 74 | coordinator = config_entry.runtime_data.coordinator 75 | 76 | entities: list[BMWButton] = [] 77 | 78 | for vehicle in coordinator.account.vehicles: 79 | entities.extend( 80 | [ 81 | BMWButton(coordinator, vehicle, description) 82 | for description in BUTTON_TYPES 83 | if (not coordinator.read_only and description.is_available(vehicle)) 84 | or (coordinator.read_only and description.enabled_when_read_only) 85 | ] 86 | ) 87 | 88 | async_add_entities(entities) 89 | 90 | 91 | class BMWButton(BMWBaseEntity, ButtonEntity): 92 | """Representation of a MyBMW button.""" 93 | 94 | entity_description: BMWButtonEntityDescription 95 | 96 | def __init__( 97 | self, 98 | coordinator: BMWDataUpdateCoordinator, 99 | vehicle: MyBMWVehicle, 100 | description: BMWButtonEntityDescription, 101 | ) -> None: 102 | """Initialize BMW vehicle sensor.""" 103 | super().__init__(coordinator, vehicle) 104 | self.entity_description = description 105 | self._attr_unique_id = f"{vehicle.vin}-{description.key}" 106 | 107 | async def async_press(self) -> None: 108 | """Press the button.""" 109 | try: 110 | await self.entity_description.remote_function(self.vehicle) 111 | except MyBMWAPIError as ex: 112 | raise HomeAssistantError(ex) from ex 113 | 114 | self.coordinator.async_update_listeners() 115 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for BMW ConnectedDrive integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Mapping 6 | from typing import Any 7 | 8 | from bimmer_connected.api.authentication import MyBMWAuthentication 9 | from bimmer_connected.api.regions import get_region_from_name 10 | from bimmer_connected.models import ( 11 | MyBMWAPIError, 12 | MyBMWAuthError, 13 | MyBMWCaptchaMissingError, 14 | ) 15 | from httpx import RequestError 16 | import voluptuous as vol 17 | 18 | from homeassistant.config_entries import ( 19 | SOURCE_REAUTH, 20 | SOURCE_RECONFIGURE, 21 | ConfigEntry, 22 | ConfigFlow, 23 | ConfigFlowResult, 24 | OptionsFlow, 25 | ) 26 | from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME 27 | from homeassistant.core import HomeAssistant, callback 28 | from homeassistant.exceptions import HomeAssistantError 29 | from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig 30 | from homeassistant.util.ssl import get_default_context 31 | 32 | from . import DOMAIN 33 | from .const import ( 34 | CONF_ALLOWED_REGIONS, 35 | CONF_CAPTCHA_REGIONS, 36 | CONF_CAPTCHA_TOKEN, 37 | CONF_CAPTCHA_URL, 38 | CONF_GCID, 39 | CONF_READ_ONLY, 40 | CONF_REFRESH_TOKEN, 41 | ) 42 | 43 | DATA_SCHEMA = vol.Schema( 44 | { 45 | vol.Required(CONF_USERNAME): str, 46 | vol.Required(CONF_PASSWORD): str, 47 | vol.Required(CONF_REGION): SelectSelector( 48 | SelectSelectorConfig( 49 | options=CONF_ALLOWED_REGIONS, 50 | translation_key="regions", 51 | ) 52 | ), 53 | }, 54 | extra=vol.REMOVE_EXTRA, 55 | ) 56 | CAPTCHA_SCHEMA = vol.Schema( 57 | { 58 | vol.Required(CONF_CAPTCHA_TOKEN): str, 59 | }, 60 | extra=vol.REMOVE_EXTRA, 61 | ) 62 | 63 | 64 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: 65 | """Validate the user input allows us to connect. 66 | 67 | Data has the keys from DATA_SCHEMA with values provided by the user. 68 | """ 69 | auth = MyBMWAuthentication( 70 | data[CONF_USERNAME], 71 | data[CONF_PASSWORD], 72 | get_region_from_name(data[CONF_REGION]), 73 | hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN), 74 | verify=get_default_context(), 75 | ) 76 | 77 | try: 78 | await auth.login() 79 | except MyBMWCaptchaMissingError as ex: 80 | raise MissingCaptcha from ex 81 | except MyBMWAuthError as ex: 82 | raise InvalidAuth from ex 83 | except (MyBMWAPIError, RequestError) as ex: 84 | raise CannotConnect from ex 85 | 86 | # Return info that you want to store in the config entry. 87 | retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"} 88 | if auth.refresh_token: 89 | retval[CONF_REFRESH_TOKEN] = auth.refresh_token 90 | if auth.gcid: 91 | retval[CONF_GCID] = auth.gcid 92 | return retval 93 | 94 | 95 | class BMWConfigFlow(ConfigFlow, domain=DOMAIN): 96 | """Handle a config flow for MyBMW.""" 97 | 98 | VERSION = 1 99 | 100 | data: dict[str, Any] = {} 101 | 102 | _existing_entry_data: Mapping[str, Any] | None = None 103 | 104 | async def async_step_user( 105 | self, user_input: dict[str, Any] | None = None 106 | ) -> ConfigFlowResult: 107 | """Handle the initial step.""" 108 | errors: dict[str, str] = self.data.pop("errors", {}) 109 | 110 | if user_input is not None and not errors: 111 | unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" 112 | await self.async_set_unique_id(unique_id) 113 | 114 | if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: 115 | self._abort_if_unique_id_mismatch(reason="account_mismatch") 116 | else: 117 | self._abort_if_unique_id_configured() 118 | 119 | # Store user input for later use 120 | self.data.update(user_input) 121 | 122 | # North America and Rest of World require captcha token 123 | if ( 124 | self.data.get(CONF_REGION) in CONF_CAPTCHA_REGIONS 125 | and CONF_CAPTCHA_TOKEN not in self.data 126 | ): 127 | return await self.async_step_captcha() 128 | 129 | info = None 130 | try: 131 | info = await validate_input(self.hass, self.data) 132 | except MissingCaptcha: 133 | errors["base"] = "missing_captcha" 134 | except CannotConnect: 135 | errors["base"] = "cannot_connect" 136 | except InvalidAuth: 137 | errors["base"] = "invalid_auth" 138 | finally: 139 | self.data.pop(CONF_CAPTCHA_TOKEN, None) 140 | 141 | if info: 142 | entry_data = { 143 | **self.data, 144 | CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), 145 | CONF_GCID: info.get(CONF_GCID), 146 | } 147 | 148 | if self.source == SOURCE_REAUTH: 149 | return self.async_update_reload_and_abort( 150 | self._get_reauth_entry(), data=entry_data 151 | ) 152 | if self.source == SOURCE_RECONFIGURE: 153 | return self.async_update_reload_and_abort( 154 | self._get_reconfigure_entry(), 155 | data=entry_data, 156 | ) 157 | return self.async_create_entry( 158 | title=info["title"], 159 | data=entry_data, 160 | ) 161 | 162 | schema = self.add_suggested_values_to_schema( 163 | DATA_SCHEMA, 164 | self._existing_entry_data or self.data, 165 | ) 166 | 167 | return self.async_show_form(step_id="user", data_schema=schema, errors=errors) 168 | 169 | async def async_step_reauth( 170 | self, entry_data: Mapping[str, Any] 171 | ) -> ConfigFlowResult: 172 | """Handle configuration by re-auth.""" 173 | self._existing_entry_data = entry_data 174 | return await self.async_step_user() 175 | 176 | async def async_step_reconfigure( 177 | self, user_input: dict[str, Any] | None = None 178 | ) -> ConfigFlowResult: 179 | """Handle a reconfiguration flow initialized by the user.""" 180 | self._existing_entry_data = self._get_reconfigure_entry().data 181 | return await self.async_step_user() 182 | 183 | async def async_step_captcha( 184 | self, user_input: dict[str, Any] | None = None 185 | ) -> ConfigFlowResult: 186 | """Show captcha form.""" 187 | if user_input and user_input.get(CONF_CAPTCHA_TOKEN): 188 | self.data[CONF_CAPTCHA_TOKEN] = user_input[CONF_CAPTCHA_TOKEN].strip() 189 | return await self.async_step_user(self.data) 190 | 191 | return self.async_show_form( 192 | step_id="captcha", 193 | data_schema=CAPTCHA_SCHEMA, 194 | description_placeholders={ 195 | "captcha_url": CONF_CAPTCHA_URL.format(region=self.data[CONF_REGION]) 196 | }, 197 | ) 198 | 199 | @staticmethod 200 | @callback 201 | def async_get_options_flow( 202 | config_entry: ConfigEntry, 203 | ) -> BMWOptionsFlow: 204 | """Return a MyBMW option flow.""" 205 | return BMWOptionsFlow() 206 | 207 | 208 | class BMWOptionsFlow(OptionsFlow): 209 | """Handle a option flow for MyBMW.""" 210 | 211 | async def async_step_init( 212 | self, user_input: dict[str, Any] | None = None 213 | ) -> ConfigFlowResult: 214 | """Manage the options.""" 215 | return await self.async_step_account_options() 216 | 217 | async def async_step_account_options( 218 | self, user_input: dict[str, Any] | None = None 219 | ) -> ConfigFlowResult: 220 | """Handle the initial step.""" 221 | if user_input is not None: 222 | # Manually update & reload the config entry after options change. 223 | # Required as each successful login will store the latest refresh_token 224 | # using async_update_entry, which would otherwise trigger a full reload 225 | # if the options would be refreshed using a listener. 226 | changed = self.hass.config_entries.async_update_entry( 227 | self.config_entry, 228 | options=user_input, 229 | ) 230 | if changed: 231 | await self.hass.config_entries.async_reload(self.config_entry.entry_id) 232 | return self.async_create_entry(title="", data=user_input) 233 | return self.async_show_form( 234 | step_id="account_options", 235 | data_schema=vol.Schema( 236 | { 237 | vol.Optional( 238 | CONF_READ_ONLY, 239 | default=self.config_entry.options.get(CONF_READ_ONLY, False), 240 | ): bool, 241 | } 242 | ), 243 | ) 244 | 245 | 246 | class CannotConnect(HomeAssistantError): 247 | """Error to indicate we cannot connect.""" 248 | 249 | 250 | class InvalidAuth(HomeAssistantError): 251 | """Error to indicate there is invalid auth.""" 252 | 253 | 254 | class MissingCaptcha(HomeAssistantError): 255 | """Error to indicate the captcha token is missing.""" 256 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/const.py: -------------------------------------------------------------------------------- 1 | """Const file for the MyBMW integration.""" 2 | 3 | from homeassistant.const import UnitOfLength, UnitOfVolume 4 | 5 | DOMAIN = "bmw_connected_drive" 6 | 7 | ATTR_DIRECTION = "direction" 8 | ATTR_VIN = "vin" 9 | 10 | CONF_ALLOWED_REGIONS = ["china", "north_america", "rest_of_world"] 11 | CONF_CAPTCHA_REGIONS = ["north_america", "rest_of_world"] 12 | CONF_READ_ONLY = "read_only" 13 | CONF_ACCOUNT = "account" 14 | CONF_REFRESH_TOKEN = "refresh_token" 15 | CONF_GCID = "gcid" 16 | CONF_CAPTCHA_TOKEN = "captcha_token" 17 | CONF_CAPTCHA_URL = ( 18 | "https://bimmer-connected.readthedocs.io/en/stable/captcha/{region}.html" 19 | ) 20 | 21 | DATA_HASS_CONFIG = "hass_config" 22 | 23 | UNIT_MAP = { 24 | "KILOMETERS": UnitOfLength.KILOMETERS, 25 | "MILES": UnitOfLength.MILES, 26 | "LITERS": UnitOfVolume.LITERS, 27 | "GALLONS": UnitOfVolume.GALLONS, 28 | } 29 | 30 | SCAN_INTERVALS = { 31 | "china": 300, 32 | "north_america": 600, 33 | "rest_of_world": 300, 34 | } 35 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/coordinator.py: -------------------------------------------------------------------------------- 1 | """Coordinator for BMW.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import timedelta 6 | import logging 7 | 8 | from bimmer_connected.account import MyBMWAccount 9 | from bimmer_connected.api.regions import get_region_from_name 10 | from bimmer_connected.models import ( 11 | GPSPosition, 12 | MyBMWAPIError, 13 | MyBMWAuthError, 14 | MyBMWCaptchaMissingError, 15 | ) 16 | from httpx import RequestError 17 | 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME 20 | from homeassistant.core import HomeAssistant 21 | from homeassistant.exceptions import ConfigEntryAuthFailed 22 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 23 | from homeassistant.util.ssl import get_default_context 24 | 25 | from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): 31 | """Class to manage fetching BMW data.""" 32 | 33 | account: MyBMWAccount 34 | 35 | def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None: 36 | """Initialize account-wide BMW data updater.""" 37 | self.account = MyBMWAccount( 38 | entry.data[CONF_USERNAME], 39 | entry.data[CONF_PASSWORD], 40 | get_region_from_name(entry.data[CONF_REGION]), 41 | observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), 42 | verify=get_default_context(), 43 | ) 44 | self.read_only = entry.options[CONF_READ_ONLY] 45 | self._entry = entry 46 | 47 | if CONF_REFRESH_TOKEN in entry.data: 48 | self.account.set_refresh_token( 49 | refresh_token=entry.data[CONF_REFRESH_TOKEN], 50 | gcid=entry.data.get(CONF_GCID), 51 | ) 52 | 53 | super().__init__( 54 | hass, 55 | _LOGGER, 56 | name=f"{DOMAIN}-{entry.data['username']}", 57 | update_interval=timedelta(seconds=SCAN_INTERVALS[entry.data[CONF_REGION]]), 58 | ) 59 | 60 | # Default to false on init so _async_update_data logic works 61 | self.last_update_success = False 62 | 63 | async def _async_update_data(self) -> None: 64 | """Fetch data from BMW.""" 65 | old_refresh_token = self.account.refresh_token 66 | 67 | try: 68 | await self.account.get_vehicles() 69 | except MyBMWCaptchaMissingError as err: 70 | # If a captcha is required (user/password login flow), always trigger the reauth flow 71 | raise ConfigEntryAuthFailed( 72 | translation_domain=DOMAIN, 73 | translation_key="missing_captcha", 74 | ) from err 75 | except MyBMWAuthError as err: 76 | # Allow one retry interval before raising AuthFailed to avoid flaky API issues 77 | if self.last_update_success: 78 | raise UpdateFailed(err) from err 79 | # Clear refresh token and trigger reauth if previous update failed as well 80 | self._update_config_entry_refresh_token(None) 81 | raise ConfigEntryAuthFailed(err) from err 82 | except (MyBMWAPIError, RequestError) as err: 83 | raise UpdateFailed(err) from err 84 | 85 | if self.account.refresh_token != old_refresh_token: 86 | self._update_config_entry_refresh_token(self.account.refresh_token) 87 | 88 | def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: 89 | """Update or delete the refresh_token in the Config Entry.""" 90 | data = { 91 | **self._entry.data, 92 | CONF_REFRESH_TOKEN: refresh_token, 93 | } 94 | if not refresh_token: 95 | data.pop(CONF_REFRESH_TOKEN) 96 | self.hass.config_entries.async_update_entry(self._entry, data=data) 97 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/device_tracker.py: -------------------------------------------------------------------------------- 1 | """Device tracker for MyBMW vehicles.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from bimmer_connected.vehicle import MyBMWVehicle 9 | 10 | from homeassistant.components.device_tracker import TrackerEntity 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | 14 | from . import BMWConfigEntry 15 | from .const import ATTR_DIRECTION 16 | from .coordinator import BMWDataUpdateCoordinator 17 | from .entity import BMWBaseEntity 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: BMWConfigEntry, 25 | async_add_entities: AddEntitiesCallback, 26 | ) -> None: 27 | """Set up the MyBMW tracker from config entry.""" 28 | coordinator = config_entry.runtime_data.coordinator 29 | entities: list[BMWDeviceTracker] = [] 30 | 31 | for vehicle in coordinator.account.vehicles: 32 | entities.append(BMWDeviceTracker(coordinator, vehicle)) 33 | if not vehicle.is_vehicle_tracking_enabled: 34 | _LOGGER.info( 35 | ( 36 | "Tracking is (currently) disabled for vehicle %s (%s), defaulting" 37 | " to unknown" 38 | ), 39 | vehicle.name, 40 | vehicle.vin, 41 | ) 42 | async_add_entities(entities) 43 | 44 | 45 | class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): 46 | """MyBMW device tracker.""" 47 | 48 | _attr_force_update = False 49 | _attr_translation_key = "car" 50 | _attr_icon = "mdi:car" 51 | 52 | def __init__( 53 | self, 54 | coordinator: BMWDataUpdateCoordinator, 55 | vehicle: MyBMWVehicle, 56 | ) -> None: 57 | """Initialize the Tracker.""" 58 | super().__init__(coordinator, vehicle) 59 | 60 | self._attr_unique_id = vehicle.vin 61 | self._attr_name = None 62 | 63 | @property 64 | def extra_state_attributes(self) -> dict[str, Any]: 65 | """Return entity specific state attributes.""" 66 | return {ATTR_DIRECTION: self.vehicle.vehicle_location.heading} 67 | 68 | @property 69 | def latitude(self) -> float | None: 70 | """Return latitude value of the device.""" 71 | return ( 72 | self.vehicle.vehicle_location.location[0] 73 | if self.vehicle.is_vehicle_tracking_enabled 74 | and self.vehicle.vehicle_location.location 75 | else None 76 | ) 77 | 78 | @property 79 | def longitude(self) -> float | None: 80 | """Return longitude value of the device.""" 81 | return ( 82 | self.vehicle.vehicle_location.location[1] 83 | if self.vehicle.is_vehicle_tracking_enabled 84 | and self.vehicle.vehicle_location.location 85 | else None 86 | ) 87 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for the BMW Connected Drive integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import asdict 6 | import json 7 | from typing import TYPE_CHECKING, Any 8 | 9 | from bimmer_connected.utils import MyBMWJSONEncoder 10 | 11 | from homeassistant.components.diagnostics import async_redact_data 12 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers.device_registry import DeviceEntry 15 | 16 | from . import BMWConfigEntry 17 | from .const import CONF_REFRESH_TOKEN 18 | 19 | if TYPE_CHECKING: 20 | from bimmer_connected.vehicle import MyBMWVehicle 21 | 22 | 23 | TO_REDACT_INFO = [CONF_USERNAME, CONF_PASSWORD, CONF_REFRESH_TOKEN] 24 | TO_REDACT_DATA = [ 25 | "lat", 26 | "latitude", 27 | "lon", 28 | "longitude", 29 | "heading", 30 | "vin", 31 | "licensePlate", 32 | "city", 33 | "street", 34 | "streetNumber", 35 | "postalCode", 36 | "phone", 37 | "formatted", 38 | "subtitle", 39 | ] 40 | 41 | 42 | def vehicle_to_dict(vehicle: MyBMWVehicle | None) -> dict: 43 | """Convert a MyBMWVehicle to a dictionary using MyBMWJSONEncoder.""" 44 | retval: dict = json.loads(json.dumps(vehicle, cls=MyBMWJSONEncoder)) 45 | return retval 46 | 47 | 48 | async def async_get_config_entry_diagnostics( 49 | hass: HomeAssistant, config_entry: BMWConfigEntry 50 | ) -> dict[str, Any]: 51 | """Return diagnostics for a config entry.""" 52 | coordinator = config_entry.runtime_data.coordinator 53 | 54 | coordinator.account.config.log_responses = True 55 | await coordinator.account.get_vehicles(force_init=True) 56 | 57 | diagnostics_data = { 58 | "info": async_redact_data(config_entry.data, TO_REDACT_INFO), 59 | "data": [ 60 | async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA) 61 | for vehicle in coordinator.account.vehicles 62 | ], 63 | "fingerprint": async_redact_data( 64 | [asdict(r) for r in coordinator.account.get_stored_responses()], 65 | TO_REDACT_DATA, 66 | ), 67 | } 68 | 69 | coordinator.account.config.log_responses = False 70 | 71 | return diagnostics_data 72 | 73 | 74 | async def async_get_device_diagnostics( 75 | hass: HomeAssistant, config_entry: BMWConfigEntry, device: DeviceEntry 76 | ) -> dict[str, Any]: 77 | """Return diagnostics for a device.""" 78 | coordinator = config_entry.runtime_data.coordinator 79 | 80 | coordinator.account.config.log_responses = True 81 | await coordinator.account.get_vehicles(force_init=True) 82 | 83 | vin = next(iter(device.identifiers))[1] 84 | vehicle = coordinator.account.get_vehicle(vin) 85 | 86 | diagnostics_data = { 87 | "info": async_redact_data(config_entry.data, TO_REDACT_INFO), 88 | "data": async_redact_data(vehicle_to_dict(vehicle), TO_REDACT_DATA), 89 | # Always have to get the full fingerprint as the VIN is redacted beforehand by the library 90 | "fingerprint": async_redact_data( 91 | [asdict(r) for r in coordinator.account.get_stored_responses()], 92 | TO_REDACT_DATA, 93 | ), 94 | } 95 | 96 | coordinator.account.config.log_responses = False 97 | 98 | return diagnostics_data 99 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/entity.py: -------------------------------------------------------------------------------- 1 | """Base for all BMW entities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from bimmer_connected.vehicle import MyBMWVehicle 6 | 7 | from homeassistant.helpers.device_registry import DeviceInfo 8 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 9 | 10 | from .const import DOMAIN 11 | from .coordinator import BMWDataUpdateCoordinator 12 | 13 | 14 | class BMWBaseEntity(CoordinatorEntity[BMWDataUpdateCoordinator]): 15 | """Common base for BMW entities.""" 16 | 17 | _attr_has_entity_name = True 18 | 19 | def __init__( 20 | self, 21 | coordinator: BMWDataUpdateCoordinator, 22 | vehicle: MyBMWVehicle, 23 | ) -> None: 24 | """Initialize entity.""" 25 | super().__init__(coordinator) 26 | 27 | self.vehicle = vehicle 28 | 29 | self._attr_device_info = DeviceInfo( 30 | identifiers={(DOMAIN, vehicle.vin)}, 31 | manufacturer=vehicle.brand.name, 32 | model=vehicle.name, 33 | name=vehicle.name, 34 | serial_number=vehicle.vin, 35 | ) 36 | 37 | async def async_added_to_hass(self) -> None: 38 | """When entity is added to hass.""" 39 | await super().async_added_to_hass() 40 | self._handle_coordinator_update() 41 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "binary_sensor": { 4 | "lids": { 5 | "default": "mdi:car-door-lock" 6 | }, 7 | "windows": { 8 | "default": "mdi:car-door" 9 | }, 10 | "door_lock_state": { 11 | "default": "mdi:car-key" 12 | }, 13 | "condition_based_services": { 14 | "default": "mdi:wrench" 15 | }, 16 | "check_control_messages": { 17 | "default": "mdi:car-tire-alert" 18 | }, 19 | "charging_status": { 20 | "default": "mdi:ev-station" 21 | }, 22 | "connection_status": { 23 | "default": "mdi:car-electric" 24 | }, 25 | "is_pre_entry_climatization_enabled": { 26 | "default": "mdi:car-seat-heater" 27 | } 28 | }, 29 | "button": { 30 | "light_flash": { 31 | "default": "mdi:car-light-alert" 32 | }, 33 | "sound_horn": { 34 | "default": "mdi:bullhorn" 35 | }, 36 | "activate_air_conditioning": { 37 | "default": "mdi:hvac" 38 | }, 39 | "deactivate_air_conditioning": { 40 | "default": "mdi:hvac-off" 41 | }, 42 | "find_vehicle": { 43 | "default": "mdi:crosshairs-question" 44 | } 45 | }, 46 | "device_tracker": { 47 | "car": { 48 | "default": "mdi:car" 49 | } 50 | }, 51 | "number": { 52 | "target_soc": { 53 | "default": "mdi:battery-charging-medium" 54 | } 55 | }, 56 | "select": { 57 | "ac_limit": { 58 | "default": "mdi:current-ac" 59 | }, 60 | "charging_mode": { 61 | "default": "mdi:vector-point-select" 62 | } 63 | }, 64 | "sensor": { 65 | "charging_status": { 66 | "default": "mdi:ev-station" 67 | }, 68 | "charging_target": { 69 | "default": "mdi:battery-charging-high" 70 | }, 71 | "mileage": { 72 | "default": "mdi:speedometer" 73 | }, 74 | "remaining_range_total": { 75 | "default": "mdi:map-marker-distance" 76 | }, 77 | "remaining_range_electric": { 78 | "default": "mdi:map-marker-distance" 79 | }, 80 | "remaining_range_fuel": { 81 | "default": "mdi:map-marker-distance" 82 | }, 83 | "remaining_fuel": { 84 | "default": "mdi:gas-station" 85 | }, 86 | "remaining_fuel_percent": { 87 | "default": "mdi:gas-station" 88 | }, 89 | "climate_status": { 90 | "default": "mdi:fan" 91 | } 92 | }, 93 | "switch": { 94 | "climate": { 95 | "default": "mdi:fan" 96 | }, 97 | "charging": { 98 | "default": "mdi:ev-station" 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/lock.py: -------------------------------------------------------------------------------- 1 | """Support for BMW car locks with BMW ConnectedDrive.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from bimmer_connected.models import MyBMWAPIError 9 | from bimmer_connected.vehicle import MyBMWVehicle 10 | from bimmer_connected.vehicle.doors_windows import LockState 11 | 12 | from homeassistant.components.lock import LockEntity 13 | from homeassistant.core import HomeAssistant, callback 14 | from homeassistant.exceptions import HomeAssistantError 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | 17 | from . import BMWConfigEntry 18 | from .coordinator import BMWDataUpdateCoordinator 19 | from .entity import BMWBaseEntity 20 | 21 | DOOR_LOCK_STATE = "door_lock_state" 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | async def async_setup_entry( 26 | hass: HomeAssistant, 27 | config_entry: BMWConfigEntry, 28 | async_add_entities: AddEntitiesCallback, 29 | ) -> None: 30 | """Set up the MyBMW lock from config entry.""" 31 | coordinator = config_entry.runtime_data.coordinator 32 | 33 | if not coordinator.read_only: 34 | async_add_entities( 35 | BMWLock(coordinator, vehicle) for vehicle in coordinator.account.vehicles 36 | ) 37 | 38 | 39 | class BMWLock(BMWBaseEntity, LockEntity): 40 | """Representation of a MyBMW vehicle lock.""" 41 | 42 | _attr_translation_key = "lock" 43 | 44 | def __init__( 45 | self, 46 | coordinator: BMWDataUpdateCoordinator, 47 | vehicle: MyBMWVehicle, 48 | ) -> None: 49 | """Initialize the lock.""" 50 | super().__init__(coordinator, vehicle) 51 | 52 | self._attr_unique_id = f"{vehicle.vin}-lock" 53 | self.door_lock_state_available = vehicle.is_lsc_enabled 54 | 55 | async def async_lock(self, **kwargs: Any) -> None: 56 | """Lock the car.""" 57 | _LOGGER.debug("%s: locking doors", self.vehicle.name) 58 | # Only update the HA state machine if the vehicle reliably reports its lock state 59 | if self.door_lock_state_available: 60 | # Optimistic state set here because it takes some time before the 61 | # update callback response 62 | self._attr_is_locked = True 63 | self.async_write_ha_state() 64 | try: 65 | await self.vehicle.remote_services.trigger_remote_door_lock() 66 | except MyBMWAPIError as ex: 67 | # Set the state to unknown if the command fails 68 | self._attr_is_locked = None 69 | self.async_write_ha_state() 70 | raise HomeAssistantError(ex) from ex 71 | finally: 72 | # Always update the listeners to get the latest state 73 | self.coordinator.async_update_listeners() 74 | 75 | async def async_unlock(self, **kwargs: Any) -> None: 76 | """Unlock the car.""" 77 | _LOGGER.debug("%s: unlocking doors", self.vehicle.name) 78 | # Only update the HA state machine if the vehicle reliably reports its lock state 79 | if self.door_lock_state_available: 80 | # Optimistic state set here because it takes some time before the 81 | # update callback response 82 | self._attr_is_locked = False 83 | self.async_write_ha_state() 84 | try: 85 | await self.vehicle.remote_services.trigger_remote_door_unlock() 86 | except MyBMWAPIError as ex: 87 | # Set the state to unknown if the command fails 88 | self._attr_is_locked = None 89 | self.async_write_ha_state() 90 | raise HomeAssistantError(ex) from ex 91 | finally: 92 | # Always update the listeners to get the latest state 93 | self.coordinator.async_update_listeners() 94 | 95 | @callback 96 | def _handle_coordinator_update(self) -> None: 97 | """Handle updated data from the coordinator.""" 98 | _LOGGER.debug("Updating lock data of %s", self.vehicle.name) 99 | 100 | # Only update the HA state machine if the vehicle reliably reports its lock state 101 | if self.door_lock_state_available: 102 | self._attr_is_locked = self.vehicle.doors_and_windows.door_lock_state in { 103 | LockState.LOCKED, 104 | LockState.SECURED, 105 | } 106 | self._attr_extra_state_attributes = { 107 | DOOR_LOCK_STATE: self.vehicle.doors_and_windows.door_lock_state.value 108 | } 109 | 110 | super()._handle_coordinator_update() 111 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "bmw_connected_drive", 3 | "name": "BMW Connected Drive BETA", 4 | "codeowners": ["@gerard33", "@rikroe"], 5 | "config_flow": true, 6 | "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", 7 | "iot_class": "cloud_polling", 8 | "loggers": ["bimmer_connected"], 9 | "version": "20241202.1", 10 | "requirements": ["bimmer-connected[china]==0.17.2"] 11 | } 12 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/notify.py: -------------------------------------------------------------------------------- 1 | """Support for BMW notifications.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any, cast 7 | 8 | from bimmer_connected.models import MyBMWAPIError, PointOfInterest 9 | from bimmer_connected.vehicle import MyBMWVehicle 10 | import voluptuous as vol 11 | 12 | from homeassistant.components.notify import ( 13 | ATTR_DATA, 14 | ATTR_TARGET, 15 | BaseNotificationService, 16 | ) 17 | from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ENTITY_ID 18 | from homeassistant.core import HomeAssistant 19 | from homeassistant.exceptions import HomeAssistantError, ServiceValidationError 20 | from homeassistant.helpers import config_validation as cv 21 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 22 | 23 | from . import DOMAIN, BMWConfigEntry 24 | 25 | ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"] 26 | 27 | POI_SCHEMA = vol.Schema( 28 | { 29 | vol.Required(ATTR_LATITUDE): cv.latitude, 30 | vol.Required(ATTR_LONGITUDE): cv.longitude, 31 | vol.Optional("street"): cv.string, 32 | vol.Optional("city"): cv.string, 33 | vol.Optional("postal_code"): cv.string, 34 | vol.Optional("country"): cv.string, 35 | } 36 | ) 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | 41 | def get_service( 42 | hass: HomeAssistant, 43 | config: ConfigType, 44 | discovery_info: DiscoveryInfoType | None = None, 45 | ) -> BMWNotificationService: 46 | """Get the BMW notification service.""" 47 | config_entry: BMWConfigEntry | None = hass.config_entries.async_get_entry( 48 | (discovery_info or {})[CONF_ENTITY_ID] 49 | ) 50 | 51 | targets = {} 52 | if ( 53 | config_entry 54 | and (coordinator := config_entry.runtime_data.coordinator) 55 | and not coordinator.read_only 56 | ): 57 | targets.update({v.name: v for v in coordinator.account.vehicles}) 58 | return BMWNotificationService(targets) 59 | 60 | 61 | class BMWNotificationService(BaseNotificationService): 62 | """Send Notifications to BMW.""" 63 | 64 | vehicle_targets: dict[str, MyBMWVehicle] 65 | 66 | def __init__(self, targets: dict[str, MyBMWVehicle]) -> None: 67 | """Set up the notification service.""" 68 | self.vehicle_targets = targets 69 | 70 | @property 71 | def targets(self) -> dict[str, Any] | None: 72 | """Return a dictionary of registered targets.""" 73 | return self.vehicle_targets 74 | 75 | async def async_send_message(self, message: str = "", **kwargs: Any) -> None: 76 | """Send a message or POI to the car.""" 77 | 78 | try: 79 | # Verify data schema 80 | poi_data = kwargs.get(ATTR_DATA) or {} 81 | POI_SCHEMA(poi_data) 82 | 83 | # Create the POI object 84 | poi = PointOfInterest( 85 | lat=poi_data.pop(ATTR_LATITUDE), 86 | lon=poi_data.pop(ATTR_LONGITUDE), 87 | name=(message or None), 88 | **poi_data, 89 | ) 90 | 91 | except (vol.Invalid, TypeError, ValueError) as ex: 92 | raise ServiceValidationError( 93 | translation_domain=DOMAIN, 94 | translation_key="invalid_poi", 95 | translation_placeholders={ 96 | "poi_exception": str(ex), 97 | }, 98 | ) from ex 99 | 100 | for vehicle in kwargs[ATTR_TARGET]: 101 | vehicle = cast(MyBMWVehicle, vehicle) 102 | _LOGGER.debug("Sending message to %s", vehicle.name) 103 | 104 | try: 105 | await vehicle.remote_services.trigger_send_poi(poi) 106 | except MyBMWAPIError as ex: 107 | raise HomeAssistantError(ex) from ex 108 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/number.py: -------------------------------------------------------------------------------- 1 | """Number platform for BMW.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | import logging 6 | from typing import Any 7 | 8 | from bimmer_connected.models import MyBMWAPIError 9 | from bimmer_connected.vehicle import MyBMWVehicle 10 | 11 | from homeassistant.components.number import ( 12 | NumberDeviceClass, 13 | NumberEntity, 14 | NumberEntityDescription, 15 | NumberMode, 16 | ) 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.exceptions import HomeAssistantError 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | 21 | from . import BMWConfigEntry 22 | from .coordinator import BMWDataUpdateCoordinator 23 | from .entity import BMWBaseEntity 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | @dataclass(frozen=True, kw_only=True) 29 | class BMWNumberEntityDescription(NumberEntityDescription): 30 | """Describes BMW number entity.""" 31 | 32 | value_fn: Callable[[MyBMWVehicle], float | int | None] 33 | remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] 34 | is_available: Callable[[MyBMWVehicle], bool] = lambda _: False 35 | dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None 36 | 37 | 38 | NUMBER_TYPES: list[BMWNumberEntityDescription] = [ 39 | BMWNumberEntityDescription( 40 | key="target_soc", 41 | translation_key="target_soc", 42 | device_class=NumberDeviceClass.BATTERY, 43 | is_available=lambda v: v.is_remote_set_target_soc_enabled, 44 | native_max_value=100.0, 45 | native_min_value=20.0, 46 | native_step=5.0, 47 | mode=NumberMode.SLIDER, 48 | value_fn=lambda v: v.fuel_and_battery.charging_target, 49 | remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( 50 | target_soc=int(o) 51 | ), 52 | ), 53 | ] 54 | 55 | 56 | async def async_setup_entry( 57 | hass: HomeAssistant, 58 | config_entry: BMWConfigEntry, 59 | async_add_entities: AddEntitiesCallback, 60 | ) -> None: 61 | """Set up the MyBMW number from config entry.""" 62 | coordinator = config_entry.runtime_data.coordinator 63 | 64 | entities: list[BMWNumber] = [] 65 | 66 | for vehicle in coordinator.account.vehicles: 67 | if not coordinator.read_only: 68 | entities.extend( 69 | [ 70 | BMWNumber(coordinator, vehicle, description) 71 | for description in NUMBER_TYPES 72 | if description.is_available(vehicle) 73 | ] 74 | ) 75 | async_add_entities(entities) 76 | 77 | 78 | class BMWNumber(BMWBaseEntity, NumberEntity): 79 | """Representation of BMW Number entity.""" 80 | 81 | entity_description: BMWNumberEntityDescription 82 | 83 | def __init__( 84 | self, 85 | coordinator: BMWDataUpdateCoordinator, 86 | vehicle: MyBMWVehicle, 87 | description: BMWNumberEntityDescription, 88 | ) -> None: 89 | """Initialize an BMW Number.""" 90 | super().__init__(coordinator, vehicle) 91 | self.entity_description = description 92 | self._attr_unique_id = f"{vehicle.vin}-{description.key}" 93 | 94 | @property 95 | def native_value(self) -> float | None: 96 | """Return the entity value to represent the entity state.""" 97 | return self.entity_description.value_fn(self.vehicle) 98 | 99 | async def async_set_native_value(self, value: float) -> None: 100 | """Update to the vehicle.""" 101 | _LOGGER.debug( 102 | "Executing '%s' on vehicle '%s' to value '%s'", 103 | self.entity_description.key, 104 | self.vehicle.vin, 105 | value, 106 | ) 107 | try: 108 | await self.entity_description.remote_service(self.vehicle, value) 109 | except MyBMWAPIError as ex: 110 | raise HomeAssistantError(ex) from ex 111 | 112 | self.coordinator.async_update_listeners() 113 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/select.py: -------------------------------------------------------------------------------- 1 | """Select platform for BMW.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | import logging 6 | from typing import Any 7 | 8 | from bimmer_connected.models import MyBMWAPIError 9 | from bimmer_connected.vehicle import MyBMWVehicle 10 | from bimmer_connected.vehicle.charging_profile import ChargingMode 11 | 12 | from homeassistant.components.select import SelectEntity, SelectEntityDescription 13 | from homeassistant.const import UnitOfElectricCurrent 14 | from homeassistant.core import HomeAssistant, callback 15 | from homeassistant.exceptions import HomeAssistantError 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | 18 | from . import BMWConfigEntry 19 | from .coordinator import BMWDataUpdateCoordinator 20 | from .entity import BMWBaseEntity 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | @dataclass(frozen=True, kw_only=True) 26 | class BMWSelectEntityDescription(SelectEntityDescription): 27 | """Describes BMW sensor entity.""" 28 | 29 | current_option: Callable[[MyBMWVehicle], str] 30 | remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] 31 | is_available: Callable[[MyBMWVehicle], bool] = lambda _: False 32 | dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None 33 | 34 | 35 | SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = ( 36 | BMWSelectEntityDescription( 37 | key="ac_limit", 38 | translation_key="ac_limit", 39 | is_available=lambda v: v.is_remote_set_ac_limit_enabled, 40 | dynamic_options=lambda v: [ 41 | str(lim) 42 | for lim in v.charging_profile.ac_available_limits # type: ignore[union-attr] 43 | ], 44 | current_option=lambda v: str(v.charging_profile.ac_current_limit), # type: ignore[union-attr] 45 | remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( 46 | ac_limit=int(o) 47 | ), 48 | unit_of_measurement=UnitOfElectricCurrent.AMPERE, 49 | ), 50 | BMWSelectEntityDescription( 51 | key="charging_mode", 52 | translation_key="charging_mode", 53 | is_available=lambda v: v.is_charging_plan_supported, 54 | options=[c.value.lower() for c in ChargingMode if c != ChargingMode.UNKNOWN], 55 | current_option=lambda v: v.charging_profile.charging_mode.value.lower(), # type: ignore[union-attr] 56 | remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( 57 | charging_mode=ChargingMode(o) 58 | ), 59 | ), 60 | ) 61 | 62 | 63 | async def async_setup_entry( 64 | hass: HomeAssistant, 65 | config_entry: BMWConfigEntry, 66 | async_add_entities: AddEntitiesCallback, 67 | ) -> None: 68 | """Set up the MyBMW lock from config entry.""" 69 | coordinator = config_entry.runtime_data.coordinator 70 | 71 | entities: list[BMWSelect] = [] 72 | 73 | for vehicle in coordinator.account.vehicles: 74 | if not coordinator.read_only: 75 | entities.extend( 76 | [ 77 | BMWSelect(coordinator, vehicle, description) 78 | for description in SELECT_TYPES 79 | if description.is_available(vehicle) 80 | ] 81 | ) 82 | async_add_entities(entities) 83 | 84 | 85 | class BMWSelect(BMWBaseEntity, SelectEntity): 86 | """Representation of BMW select entity.""" 87 | 88 | entity_description: BMWSelectEntityDescription 89 | 90 | def __init__( 91 | self, 92 | coordinator: BMWDataUpdateCoordinator, 93 | vehicle: MyBMWVehicle, 94 | description: BMWSelectEntityDescription, 95 | ) -> None: 96 | """Initialize an BMW select.""" 97 | super().__init__(coordinator, vehicle) 98 | self.entity_description = description 99 | self._attr_unique_id = f"{vehicle.vin}-{description.key}" 100 | if description.dynamic_options: 101 | self._attr_options = description.dynamic_options(vehicle) 102 | self._attr_current_option = description.current_option(vehicle) 103 | 104 | @callback 105 | def _handle_coordinator_update(self) -> None: 106 | """Handle updated data from the coordinator.""" 107 | _LOGGER.debug( 108 | "Updating select '%s' of %s", self.entity_description.key, self.vehicle.name 109 | ) 110 | self._attr_current_option = self.entity_description.current_option(self.vehicle) 111 | super()._handle_coordinator_update() 112 | 113 | async def async_select_option(self, option: str) -> None: 114 | """Update to the vehicle.""" 115 | _LOGGER.debug( 116 | "Executing '%s' on vehicle '%s' to value '%s'", 117 | self.entity_description.key, 118 | self.vehicle.vin, 119 | option, 120 | ) 121 | try: 122 | await self.entity_description.remote_service(self.vehicle, option) 123 | except MyBMWAPIError as ex: 124 | raise HomeAssistantError(ex) from ex 125 | 126 | self.coordinator.async_update_listeners() 127 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for reading vehicle status from MyBMW portal.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable 6 | from dataclasses import dataclass 7 | import datetime 8 | import logging 9 | 10 | from bimmer_connected.models import StrEnum, ValueWithUnit 11 | from bimmer_connected.vehicle import MyBMWVehicle 12 | from bimmer_connected.vehicle.climate import ClimateActivityState 13 | from bimmer_connected.vehicle.fuel_and_battery import ChargingState 14 | 15 | from homeassistant.components.sensor import ( 16 | SensorDeviceClass, 17 | SensorEntity, 18 | SensorEntityDescription, 19 | SensorStateClass, 20 | ) 21 | from homeassistant.const import ( 22 | PERCENTAGE, 23 | STATE_UNKNOWN, 24 | UnitOfElectricCurrent, 25 | UnitOfLength, 26 | UnitOfPressure, 27 | UnitOfVolume, 28 | ) 29 | from homeassistant.core import HomeAssistant, callback 30 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 31 | from homeassistant.util import dt as dt_util 32 | 33 | from . import BMWConfigEntry 34 | from .coordinator import BMWDataUpdateCoordinator 35 | from .entity import BMWBaseEntity 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | @dataclass(frozen=True) 41 | class BMWSensorEntityDescription(SensorEntityDescription): 42 | """Describes BMW sensor entity.""" 43 | 44 | key_class: str | None = None 45 | is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled 46 | 47 | 48 | TIRES = ["front_left", "front_right", "rear_left", "rear_right"] 49 | 50 | SENSOR_TYPES: list[BMWSensorEntityDescription] = [ 51 | BMWSensorEntityDescription( 52 | key="charging_profile.ac_current_limit", 53 | translation_key="ac_current_limit", 54 | device_class=SensorDeviceClass.CURRENT, 55 | native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, 56 | entity_registry_enabled_default=False, 57 | suggested_display_precision=0, 58 | is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, 59 | ), 60 | BMWSensorEntityDescription( 61 | key="fuel_and_battery.charging_start_time", 62 | translation_key="charging_start_time", 63 | device_class=SensorDeviceClass.TIMESTAMP, 64 | entity_registry_enabled_default=False, 65 | is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, 66 | ), 67 | BMWSensorEntityDescription( 68 | key="fuel_and_battery.charging_end_time", 69 | translation_key="charging_end_time", 70 | device_class=SensorDeviceClass.TIMESTAMP, 71 | is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, 72 | ), 73 | BMWSensorEntityDescription( 74 | key="fuel_and_battery.charging_status", 75 | translation_key="charging_status", 76 | device_class=SensorDeviceClass.ENUM, 77 | options=[s.value.lower() for s in ChargingState if s != ChargingState.UNKNOWN], 78 | is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, 79 | ), 80 | BMWSensorEntityDescription( 81 | key="fuel_and_battery.charging_target", 82 | translation_key="charging_target", 83 | native_unit_of_measurement=PERCENTAGE, 84 | suggested_display_precision=0, 85 | is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, 86 | ), 87 | BMWSensorEntityDescription( 88 | key="fuel_and_battery.remaining_battery_percent", 89 | translation_key="remaining_battery_percent", 90 | device_class=SensorDeviceClass.BATTERY, 91 | native_unit_of_measurement=PERCENTAGE, 92 | state_class=SensorStateClass.MEASUREMENT, 93 | suggested_display_precision=0, 94 | is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, 95 | ), 96 | BMWSensorEntityDescription( 97 | key="mileage", 98 | translation_key="mileage", 99 | device_class=SensorDeviceClass.DISTANCE, 100 | native_unit_of_measurement=UnitOfLength.KILOMETERS, 101 | state_class=SensorStateClass.TOTAL_INCREASING, 102 | suggested_display_precision=0, 103 | ), 104 | BMWSensorEntityDescription( 105 | key="fuel_and_battery.remaining_range_total", 106 | translation_key="remaining_range_total", 107 | device_class=SensorDeviceClass.DISTANCE, 108 | native_unit_of_measurement=UnitOfLength.KILOMETERS, 109 | state_class=SensorStateClass.MEASUREMENT, 110 | suggested_display_precision=0, 111 | ), 112 | BMWSensorEntityDescription( 113 | key="fuel_and_battery.remaining_range_electric", 114 | translation_key="remaining_range_electric", 115 | device_class=SensorDeviceClass.DISTANCE, 116 | native_unit_of_measurement=UnitOfLength.KILOMETERS, 117 | state_class=SensorStateClass.MEASUREMENT, 118 | suggested_display_precision=0, 119 | is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, 120 | ), 121 | BMWSensorEntityDescription( 122 | key="fuel_and_battery.remaining_range_fuel", 123 | translation_key="remaining_range_fuel", 124 | device_class=SensorDeviceClass.DISTANCE, 125 | native_unit_of_measurement=UnitOfLength.KILOMETERS, 126 | state_class=SensorStateClass.MEASUREMENT, 127 | suggested_display_precision=0, 128 | is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, 129 | ), 130 | BMWSensorEntityDescription( 131 | key="fuel_and_battery.remaining_fuel", 132 | translation_key="remaining_fuel", 133 | device_class=SensorDeviceClass.VOLUME_STORAGE, 134 | native_unit_of_measurement=UnitOfVolume.LITERS, 135 | state_class=SensorStateClass.MEASUREMENT, 136 | suggested_display_precision=0, 137 | is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, 138 | ), 139 | BMWSensorEntityDescription( 140 | key="fuel_and_battery.remaining_fuel_percent", 141 | translation_key="remaining_fuel_percent", 142 | native_unit_of_measurement=PERCENTAGE, 143 | state_class=SensorStateClass.MEASUREMENT, 144 | suggested_display_precision=0, 145 | is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, 146 | ), 147 | BMWSensorEntityDescription( 148 | key="climate.activity", 149 | translation_key="climate_status", 150 | device_class=SensorDeviceClass.ENUM, 151 | options=[ 152 | s.value.lower() 153 | for s in ClimateActivityState 154 | if s != ClimateActivityState.UNKNOWN 155 | ], 156 | is_available=lambda v: v.is_remote_climate_stop_enabled, 157 | ), 158 | *[ 159 | BMWSensorEntityDescription( 160 | key=f"tires.{tire}.current_pressure", 161 | translation_key=f"{tire}_current_pressure", 162 | device_class=SensorDeviceClass.PRESSURE, 163 | native_unit_of_measurement=UnitOfPressure.KPA, 164 | suggested_unit_of_measurement=UnitOfPressure.BAR, 165 | state_class=SensorStateClass.MEASUREMENT, 166 | suggested_display_precision=2, 167 | is_available=lambda v: v.is_lsc_enabled and v.tires is not None, 168 | ) 169 | for tire in TIRES 170 | ], 171 | *[ 172 | BMWSensorEntityDescription( 173 | key=f"tires.{tire}.target_pressure", 174 | translation_key=f"{tire}_target_pressure", 175 | device_class=SensorDeviceClass.PRESSURE, 176 | native_unit_of_measurement=UnitOfPressure.KPA, 177 | suggested_unit_of_measurement=UnitOfPressure.BAR, 178 | state_class=SensorStateClass.MEASUREMENT, 179 | suggested_display_precision=2, 180 | entity_registry_enabled_default=False, 181 | is_available=lambda v: v.is_lsc_enabled and v.tires is not None, 182 | ) 183 | for tire in TIRES 184 | ], 185 | ] 186 | 187 | 188 | async def async_setup_entry( 189 | hass: HomeAssistant, 190 | config_entry: BMWConfigEntry, 191 | async_add_entities: AddEntitiesCallback, 192 | ) -> None: 193 | """Set up the MyBMW sensors from config entry.""" 194 | coordinator = config_entry.runtime_data.coordinator 195 | 196 | entities = [ 197 | BMWSensor(coordinator, vehicle, description) 198 | for vehicle in coordinator.account.vehicles 199 | for description in SENSOR_TYPES 200 | if description.is_available(vehicle) 201 | ] 202 | 203 | async_add_entities(entities) 204 | 205 | 206 | class BMWSensor(BMWBaseEntity, SensorEntity): 207 | """Representation of a BMW vehicle sensor.""" 208 | 209 | entity_description: BMWSensorEntityDescription 210 | 211 | def __init__( 212 | self, 213 | coordinator: BMWDataUpdateCoordinator, 214 | vehicle: MyBMWVehicle, 215 | description: BMWSensorEntityDescription, 216 | ) -> None: 217 | """Initialize BMW vehicle sensor.""" 218 | super().__init__(coordinator, vehicle) 219 | self.entity_description = description 220 | self._attr_unique_id = f"{vehicle.vin}-{description.key}" 221 | 222 | @callback 223 | def _handle_coordinator_update(self) -> None: 224 | """Handle updated data from the coordinator.""" 225 | _LOGGER.debug( 226 | "Updating sensor '%s' of %s", self.entity_description.key, self.vehicle.name 227 | ) 228 | 229 | key_path = self.entity_description.key.split(".") 230 | state = getattr(self.vehicle, key_path.pop(0)) 231 | 232 | for key in key_path: 233 | state = getattr(state, key) 234 | 235 | # For datetime without tzinfo, we assume it to be the same timezone as the HA instance 236 | if isinstance(state, datetime.datetime) and state.tzinfo is None: 237 | state = state.replace(tzinfo=dt_util.get_default_time_zone()) 238 | # For enum types, we only want the value 239 | elif isinstance(state, ValueWithUnit): 240 | state = state.value 241 | # Get lowercase values from StrEnum 242 | elif isinstance(state, StrEnum): 243 | state = state.value.lower() 244 | if state == STATE_UNKNOWN: 245 | state = None 246 | 247 | self._attr_native_value = state 248 | super()._handle_coordinator_update() 249 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "username": "[%key:common::config_flow::data::username%]", 7 | "password": "[%key:common::config_flow::data::password%]", 8 | "region": "ConnectedDrive Region" 9 | } 10 | }, 11 | "captcha": { 12 | "title": "Are you a robot?", 13 | "description": "A captcha is required for BMW login. Visit the external website to complete the challenge and submit the form. Copy the resulting token into the field below.\n\n{captcha_url}\n\nNo data will be exposed outside of your Home Assistant instance.", 14 | "data": { 15 | "captcha_token": "Captcha token" 16 | }, 17 | "data_description": { 18 | "captcha_token": "One-time token retrieved from the captcha challenge." 19 | } 20 | } 21 | }, 22 | "error": { 23 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 24 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 25 | "missing_captcha": "Captcha validation missing" 26 | }, 27 | "abort": { 28 | "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", 29 | "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", 30 | "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", 31 | "account_mismatch": "Username and region are not allowed to change" 32 | } 33 | }, 34 | "options": { 35 | "step": { 36 | "account_options": { 37 | "data": { 38 | "read_only": "Read-only (only sensors and notify, no execution of services, no lock)" 39 | } 40 | } 41 | } 42 | }, 43 | "entity": { 44 | "binary_sensor": { 45 | "lids": { 46 | "name": "Lids" 47 | }, 48 | "windows": { 49 | "name": "Windows" 50 | }, 51 | "door_lock_state": { 52 | "name": "Door lock state" 53 | }, 54 | "condition_based_services": { 55 | "name": "Condition based services" 56 | }, 57 | "check_control_messages": { 58 | "name": "Check control messages" 59 | }, 60 | "charging_status": { 61 | "name": "Charging status" 62 | }, 63 | "connection_status": { 64 | "name": "Connection status" 65 | }, 66 | "is_pre_entry_climatization_enabled": { 67 | "name": "Pre entry climatization" 68 | } 69 | }, 70 | "button": { 71 | "light_flash": { 72 | "name": "Flash lights" 73 | }, 74 | "sound_horn": { 75 | "name": "Sound horn" 76 | }, 77 | "activate_air_conditioning": { 78 | "name": "Activate air conditioning" 79 | }, 80 | "find_vehicle": { 81 | "name": "Find vehicle" 82 | } 83 | }, 84 | "lock": { 85 | "lock": { 86 | "name": "[%key:component::lock::title%]" 87 | } 88 | }, 89 | "number": { 90 | "target_soc": { 91 | "name": "Target SoC" 92 | } 93 | }, 94 | "select": { 95 | "ac_limit": { 96 | "name": "AC Charging Limit" 97 | }, 98 | "charging_mode": { 99 | "name": "Charging Mode", 100 | "state": { 101 | "immediate_charging": "Immediate charging", 102 | "delayed_charging": "Delayed charging", 103 | "no_action": "No action" 104 | } 105 | } 106 | }, 107 | "sensor": { 108 | "ac_current_limit": { 109 | "name": "AC current limit" 110 | }, 111 | "charging_start_time": { 112 | "name": "Charging start time" 113 | }, 114 | "charging_end_time": { 115 | "name": "Charging end time" 116 | }, 117 | "charging_status": { 118 | "name": "Charging status", 119 | "state": { 120 | "default": "Default", 121 | "charging": "Charging", 122 | "error": "Error", 123 | "complete": "Complete", 124 | "fully_charged": "Fully charged", 125 | "finished_fully_charged": "Finished, fully charged", 126 | "finished_not_full": "Finished, not full", 127 | "invalid": "Invalid", 128 | "not_charging": "Not charging", 129 | "plugged_in": "Plugged in", 130 | "waiting_for_charging": "Waiting for charging", 131 | "target_reached": "Target reached" 132 | } 133 | }, 134 | "charging_target": { 135 | "name": "Charging target" 136 | }, 137 | "remaining_battery_percent": { 138 | "name": "Remaining battery percent" 139 | }, 140 | "mileage": { 141 | "name": "Mileage" 142 | }, 143 | "remaining_range_total": { 144 | "name": "Remaining range total" 145 | }, 146 | "remaining_range_electric": { 147 | "name": "Remaining range electric" 148 | }, 149 | "remaining_range_fuel": { 150 | "name": "Remaining range fuel" 151 | }, 152 | "remaining_fuel": { 153 | "name": "Remaining fuel" 154 | }, 155 | "remaining_fuel_percent": { 156 | "name": "Remaining fuel percent" 157 | }, 158 | "climate_status": { 159 | "name": "Climate status", 160 | "state": { 161 | "cooling": "Cooling", 162 | "heating": "Heating", 163 | "inactive": "Inactive", 164 | "standby": "Standby", 165 | "ventilation": "Ventilation" 166 | } 167 | }, 168 | "front_left_current_pressure": { 169 | "name": "Front left tire pressure" 170 | }, 171 | "front_right_current_pressure": { 172 | "name": "Front right tire pressure" 173 | }, 174 | "rear_left_current_pressure": { 175 | "name": "Rear left tire pressure" 176 | }, 177 | "rear_right_current_pressure": { 178 | "name": "Rear right tire pressure" 179 | }, 180 | "front_left_target_pressure": { 181 | "name": "Front left target pressure" 182 | }, 183 | "front_right_target_pressure": { 184 | "name": "Front right target pressure" 185 | }, 186 | "rear_left_target_pressure": { 187 | "name": "Rear left target pressure" 188 | }, 189 | "rear_right_target_pressure": { 190 | "name": "Rear right target pressure" 191 | } 192 | }, 193 | "switch": { 194 | "climate": { 195 | "name": "Climate" 196 | }, 197 | "charging": { 198 | "name": "Charging" 199 | } 200 | } 201 | }, 202 | "selector": { 203 | "regions": { 204 | "options": { 205 | "china": "China", 206 | "north_america": "North America", 207 | "rest_of_world": "Rest of world" 208 | } 209 | } 210 | }, 211 | "exceptions": { 212 | "invalid_poi": { 213 | "message": "Invalid data for point of interest: {poi_exception}" 214 | }, 215 | "missing_captcha": { 216 | "message": "Login requires captcha validation" 217 | } 218 | }, 219 | "issues": { 220 | "stop_using_custom_component": { 221 | "title": "Stop using custom component", 222 | "description": "The custom component for BMW Connected Drive is outdated. Please remove the custom component and use the version shipped with Home Assistant." 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /custom_components/bmw_connected_drive/switch.py: -------------------------------------------------------------------------------- 1 | """Switch platform for BMW.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | import logging 6 | from typing import Any 7 | 8 | from bimmer_connected.models import MyBMWAPIError 9 | from bimmer_connected.vehicle import MyBMWVehicle 10 | from bimmer_connected.vehicle.fuel_and_battery import ChargingState 11 | 12 | from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.exceptions import HomeAssistantError 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | 17 | from . import BMWConfigEntry 18 | from .coordinator import BMWDataUpdateCoordinator 19 | from .entity import BMWBaseEntity 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | @dataclass(frozen=True, kw_only=True) 25 | class BMWSwitchEntityDescription(SwitchEntityDescription): 26 | """Describes BMW switch entity.""" 27 | 28 | value_fn: Callable[[MyBMWVehicle], bool] 29 | remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] 30 | remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] 31 | is_available: Callable[[MyBMWVehicle], bool] = lambda _: False 32 | dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None 33 | 34 | 35 | CHARGING_STATE_ON = { 36 | ChargingState.CHARGING, 37 | ChargingState.COMPLETE, 38 | ChargingState.FULLY_CHARGED, 39 | ChargingState.FINISHED_FULLY_CHARGED, 40 | ChargingState.FINISHED_NOT_FULL, 41 | ChargingState.TARGET_REACHED, 42 | } 43 | 44 | NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ 45 | BMWSwitchEntityDescription( 46 | key="climate", 47 | translation_key="climate", 48 | is_available=lambda v: v.is_remote_climate_stop_enabled, 49 | value_fn=lambda v: v.climate.is_climate_on, 50 | remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), 51 | remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), 52 | ), 53 | BMWSwitchEntityDescription( 54 | key="charging", 55 | translation_key="charging", 56 | is_available=lambda v: v.is_remote_charge_stop_enabled, 57 | value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON, 58 | remote_service_on=lambda v: v.remote_services.trigger_charge_start(), 59 | remote_service_off=lambda v: v.remote_services.trigger_charge_stop(), 60 | ), 61 | ] 62 | 63 | 64 | async def async_setup_entry( 65 | hass: HomeAssistant, 66 | config_entry: BMWConfigEntry, 67 | async_add_entities: AddEntitiesCallback, 68 | ) -> None: 69 | """Set up the MyBMW switch from config entry.""" 70 | coordinator = config_entry.runtime_data.coordinator 71 | 72 | entities: list[BMWSwitch] = [] 73 | 74 | for vehicle in coordinator.account.vehicles: 75 | if not coordinator.read_only: 76 | entities.extend( 77 | [ 78 | BMWSwitch(coordinator, vehicle, description) 79 | for description in NUMBER_TYPES 80 | if description.is_available(vehicle) 81 | ] 82 | ) 83 | async_add_entities(entities) 84 | 85 | 86 | class BMWSwitch(BMWBaseEntity, SwitchEntity): 87 | """Representation of BMW Switch entity.""" 88 | 89 | entity_description: BMWSwitchEntityDescription 90 | 91 | def __init__( 92 | self, 93 | coordinator: BMWDataUpdateCoordinator, 94 | vehicle: MyBMWVehicle, 95 | description: BMWSwitchEntityDescription, 96 | ) -> None: 97 | """Initialize an BMW Switch.""" 98 | super().__init__(coordinator, vehicle) 99 | self.entity_description = description 100 | self._attr_unique_id = f"{vehicle.vin}-{description.key}" 101 | 102 | @property 103 | def is_on(self) -> bool: 104 | """Return the entity value to represent the entity state.""" 105 | return self.entity_description.value_fn(self.vehicle) 106 | 107 | async def async_turn_on(self, **kwargs: Any) -> None: 108 | """Turn the switch on.""" 109 | try: 110 | await self.entity_description.remote_service_on(self.vehicle) 111 | except MyBMWAPIError as ex: 112 | raise HomeAssistantError(ex) from ex 113 | 114 | self.coordinator.async_update_listeners() 115 | 116 | async def async_turn_off(self, **kwargs: Any) -> None: 117 | """Turn the switch off.""" 118 | try: 119 | await self.entity_description.remote_service_off(self.vehicle) 120 | except MyBMWAPIError as ex: 121 | raise HomeAssistantError(ex) from ex 122 | 123 | self.coordinator.async_update_listeners() 124 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BMW Connected Drive BETA", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /pictures/example_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bimmerconnected/ha_custom_component/adfb34cf88b38121f6ec61a57641dabe66309547/pictures/example_1.jpg -------------------------------------------------------------------------------- /pictures/example_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bimmerconnected/ha_custom_component/adfb34cf88b38121f6ec61a57641dabe66309547/pictures/example_2.jpg -------------------------------------------------------------------------------- /pictures/example_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bimmerconnected/ha_custom_component/adfb34cf88b38121f6ec61a57641dabe66309547/pictures/example_3.jpg --------------------------------------------------------------------------------