Repository: arjenvrh/audi_connect_ha Branch: master Commit: 5dcc3ae09c49 Files: 42 Total size: 293.0 KB Directory structure: gitextract_84fzj90j/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── dependabot.yml │ └── workflows/ │ ├── inactiveIssues.yml │ ├── release.yml │ ├── semanticTitle.yaml │ └── validate.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── custom_components/ │ ├── audiconnect/ │ │ ├── __init__.py │ │ ├── audi_account.py │ │ ├── audi_api.py │ │ ├── audi_connect_account.py │ │ ├── audi_entity.py │ │ ├── audi_models.py │ │ ├── audi_services.py │ │ ├── binary_sensor.py │ │ ├── config_flow.py │ │ ├── const.py │ │ ├── dashboard.py │ │ ├── device_tracker.py │ │ ├── lock.py │ │ ├── manifest.json │ │ ├── sensor.py │ │ ├── services.yaml │ │ ├── strings.json │ │ ├── switch.py │ │ ├── translations/ │ │ │ ├── de.json │ │ │ ├── en.json │ │ │ ├── fi.json │ │ │ ├── fr.json │ │ │ ├── nb.json │ │ │ ├── nl.json │ │ │ ├── pt-BR.json │ │ │ └── pt.json │ │ └── util.py │ └── test.py ├── hacs.json ├── info.md └── readme.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "" labels: "" assignees: "" --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Logfile** How to enable audiconnect debugging? Settings > Integrations > Audi Connect > Enable Debug Logging Run service refresh_cloud_data Disable Debug Logging **Your Vehicle Details** Model: Year: Type (Gas/Hybrid/Electric): Region (EU/US/CA/CN): ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "" labels: "" assignees: "" --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. **Logfile** How to enable audiconnect debugging? Settings > Integrations > Audi Connect > Enable Debug Logging Run service refresh_cloud_data Disable Debug Logging **Your Vehicle Details** Model: Year: Type (ICE/PHEV/BEV): ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: # Enable version updates for Python - package-ecosystem: "github-actions" directory: "/" # Check for updates once a week schedule: interval: "weekly" ================================================ FILE: .github/workflows/inactiveIssues.yml ================================================ name: Close inactive issues on: schedule: - cron: "30 1 * * *" jobs: close-issues: runs-on: ubuntu-latest permissions: issues: write actions: write pull-requests: write steps: - uses: actions/stale@v10 with: days-before-issue-stale: 45 days-before-issue-close: 15 stale-issue-label: "stale" stale-issue-message: "This issue is stale because it has been open for 45 days with no activity. Are you still experiencing this issue? " close-issue-message: "This issue was closed because it has been inactive for 15 days since being marked as stale." days-before-pr-stale: -1 days-before-pr-close: -1 repo-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: workflow_dispatch: schedule: - cron: "0 8 * * Wed,Sun" jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Gets semantic release info id: semantic_release_info uses: jossef/action-semantic-release-info@v3.0.0 env: GITHUB_TOKEN: ${{ github.token }} - name: Update Version and Commit if: ${{steps.semantic_release_info.outputs.version != ''}} run: | echo "Version: ${{steps.semantic_release_info.outputs.version}}" sed -i "s/\"version\": \".*\"/\"version\": \"${{steps.semantic_release_info.outputs.version}}\"/g" custom_components/audiconnect/manifest.json git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add -A git commit -m "chore: bumping version to ${{steps.semantic_release_info.outputs.version}}" git tag ${{ steps.semantic_release_info.outputs.git_tag }} - name: Push changes if: ${{steps.semantic_release_info.outputs.version != ''}} uses: ad-m/github-push-action@v1.1.0 with: github_token: ${{ github.token }} tags: true - name: Create GitHub Release if: ${{steps.semantic_release_info.outputs.version != ''}} uses: ncipollo/release-action@v1 env: GITHUB_TOKEN: ${{ github.token }} with: tag: ${{ steps.semantic_release_info.outputs.git_tag }} name: ${{ steps.semantic_release_info.outputs.git_tag }} body: ${{ steps.semantic_release_info.outputs.notes }} draft: false prerelease: false ================================================ FILE: .github/workflows/semanticTitle.yaml ================================================ name: "Semantic Title" on: pull_request_target: types: - opened - edited - synchronize jobs: main: runs-on: ubuntu-latest steps: # Please look up the latest version from # https://github.com/amannn/action-semantic-pull-request/releases - uses: amannn/action-semantic-pull-request@v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/validate.yml ================================================ name: Validate on: push: pull_request: schedule: - cron: "0 0 * * *" workflow_dispatch: jobs: validate-hassfest: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Hassfest validation uses: home-assistant/actions/hassfest@master validate-hacs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: HACS validation uses: hacs/action@main with: category: integration ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .idea ================================================ FILE: .pre-commit-config.yaml ================================================ --- ci: autoupdate_commit_msg: "chore: pre-commit autoupdate" repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.10 hooks: - id: ruff args: - --fix - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell args: - --ignore-words-list=fro,hass - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 exclude_types: [csv, json] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-executables-have-shebangs stages: [manual] - id: check-json exclude: (.vscode|.devcontainer) - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade - repo: https://github.com/adrienverge/yamllint.git rev: v1.38.0 hooks: - id: yamllint exclude: (.github|.vscode|.devcontainer) - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier - repo: https://github.com/cdce8p/python-typing-update rev: v0.8.1 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. # Will require manual work, before submitting changes! # pre-commit run --hook-stage manual python-typing-update --all-files - id: python-typing-update stages: [manual] args: - --py311-plus - --force - --keep-updates files: ^(/.+)?[^/]+\.py$ - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.20.1 hooks: - id: mypy args: [--strict, --ignore-missing-imports] files: ^(/.+)?[^/]+\.py$ ================================================ FILE: CONTRIBUTING.md ================================================ # Contribution guidelines Contributing to this project should be as easy and transparent as possible, whether it's: - Reporting a bug - Discussing the current state of the code - Submitting a fix - Proposing new features ## Contributing Code Github is used to host code, to track issues and feature requests, as well as accept pull requests. Pull requests are the best way to propose changes to the codebase. 1. Fork the repo and create your branch from `master`. 2. If you've changed something, update the documentation. 3. Issue a pull request By contributing, you agree that your contributions will be licensed under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. ## Coding Style This project uses [black](https://github.com/ambv/black) to ensure the code follows a consistent style. ## Report bugs using Github's issues GitHub issues are used to track public bugs. Report a bug by [opening a new issue](../../issues/new/choose) ## Write bug reports with details **Great Bug Reports** tend to have: - A quick summary and/or background - Steps to reproduce - What you expected would happen - What actually happens - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Arjen van Rhijn @arjenvrh Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: custom_components/audiconnect/__init__.py ================================================ """Support for Audi Connect.""" from datetime import timedelta import voluptuous as vol import logging import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_RESOURCES, CONF_SCAN_INTERVAL, CONF_USERNAME, ) from homeassistant.helpers.device_registry import DeviceEntry from .audi_account import AudiAccount from .const import ( DOMAIN, CONF_REGION, CONF_MUTABLE, CONF_SCAN_INITIAL, CONF_SCAN_ACTIVE, DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, RESOURCES, COMPONENTS, CONF_API_LEVEL, DEFAULT_API_LEVEL, API_LEVELS, ) _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional( CONF_SCAN_INTERVAL, default=timedelta(minutes=DEFAULT_UPDATE_INTERVAL), ): vol.All( cv.time_period, vol.Clamp(min=timedelta(minutes=MIN_UPDATE_INTERVAL)), ), vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( cv.string ), vol.Optional(CONF_RESOURCES): vol.All( cv.ensure_list, [vol.In(RESOURCES)] ), vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_MUTABLE, default=True): cv.boolean, vol.Optional( CONF_API_LEVEL, default=API_LEVELS[DEFAULT_API_LEVEL] ): vol.All(vol.Coerce(int), vol.In(API_LEVELS)), } ) }, extra=vol.ALLOW_EXTRA, ) async def async_setup(hass, config): if hass.config_entries.async_entries(DOMAIN): return True if DOMAIN not in config: return True names = config[DOMAIN].get(CONF_NAME) if len(names) == 0: return True data = {} data[CONF_USERNAME] = config[DOMAIN].get(CONF_USERNAME) data[CONF_PASSWORD] = config[DOMAIN].get(CONF_PASSWORD) data[CONF_SCAN_INTERVAL] = config[DOMAIN].get(CONF_SCAN_INTERVAL).seconds / 60 data[CONF_REGION] = config[DOMAIN].get(CONF_REGION) data[CONF_API_LEVEL] = config[DOMAIN].get(CONF_API_LEVEL) hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data ) ) return True async def async_update_listener(hass, config_entry): _LOGGER.debug("Updates detected, reloading configuration...") await hass.config_entries.async_reload(config_entry.entry_id) async def async_setup_entry(hass, config_entry): """Set up this integration using UI.""" _LOGGER.debug("Audi Connect starting...") # Register the update listener so that changes to configuration options are applied immediately. config_entry.async_on_unload( config_entry.add_update_listener(async_update_listener) ) if DOMAIN not in hass.data: hass.data[DOMAIN] = {} """Set up the Audi Connect component.""" hass.data[DOMAIN]["devices"] = set() # Attempt to retrieve the scan interval from options, then fall back to data, or use default scan_interval = timedelta( minutes=config_entry.options.get( CONF_SCAN_INTERVAL, config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_UPDATE_INTERVAL), ) ) _LOGGER.debug("User option for CONF_SCAN_INTERVAL is %s", scan_interval) # Get Initial Scan Option - Default to True _scan_initial = config_entry.options.get(CONF_SCAN_INITIAL, True) _LOGGER.debug("User option for CONF_SCAN_INITIAL is %s.", _scan_initial) # Get Active Scan Option - Default to True _scan_active = config_entry.options.get(CONF_SCAN_ACTIVE, True) _LOGGER.debug("User option for CONF_SCAN_ACTIVE is %s.", _scan_active) account = config_entry.data.get(CONF_USERNAME) if account not in hass.data[DOMAIN]: data = hass.data[DOMAIN][account] = AudiAccount(hass, config_entry) data.init_connection() else: data = hass.data[DOMAIN][account] # Define a callback function for the timer to update data async def update_data(now): """Update the data with the latest information.""" _LOGGER.debug("ACTIVE POLLING: Requesting scheduled cloud data refresh...") await data.update(utcnow()) # Schedule the update_data function if option is true if _scan_active: _LOGGER.debug( "ACTIVE POLLING: Scheduling cloud data refresh every %d minutes.", scan_interval.seconds / 60, ) async_track_time_interval(hass, update_data, scan_interval) else: _LOGGER.debug( "ACTIVE POLLING: Active Polling at Scan Interval is turned off in user options. Skipping scheduling..." ) # Initially update the data if option is true if _scan_initial: _LOGGER.debug("Requesting initial cloud data update...") return await data.update(utcnow()) else: _LOGGER.debug( "Cloud Update at Start is turned off in user options. Skipping initial update..." ) _LOGGER.debug("Audi Connect Setup Complete") return True async def async_unload_entry(hass, config_entry): account = config_entry.data.get(CONF_USERNAME) data = hass.data[DOMAIN][account] for component in COMPONENTS: await hass.config_entries.async_forward_entry_unload( data.config_entry, component ) del hass.data[DOMAIN][account] return True async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True ================================================ FILE: custom_components/audiconnect/audi_account.py ================================================ import asyncio import logging import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.dt import utcnow from .audi_connect_account import AudiConnectAccount, AudiConnectObserver from .audi_models import VehicleData from .const import ( COMPONENTS, CONF_ACTION, CONF_CLIMATE_GLASS, CONF_CLIMATE_SEAT_FL, CONF_CLIMATE_SEAT_FR, CONF_CLIMATE_SEAT_RL, CONF_CLIMATE_SEAT_RR, CONF_CLIMATE_AT_UNLOCK, CONF_CLIMATE_MODE, CONF_CLIMATE_TEMP_C, CONF_CLIMATE_TEMP_F, CONF_REGION, CONF_SPIN, CONF_VIN, DOMAIN, SIGNAL_STATE_UPDATED, TRACKER_UPDATE, UPDATE_SLEEP, CONF_API_LEVEL, DEFAULT_API_LEVEL, API_LEVELS, CONF_DURATION, CONF_TARGET_SOC, CONF_FILTER_VINS, ) from .dashboard import Dashboard REFRESH_VEHICLE_DATA_FAILED_EVENT = "refresh_failed" REFRESH_VEHICLE_DATA_COMPLETED_EVENT = "refresh_completed" SERVICE_REFRESH_VEHICLE_DATA = "refresh_vehicle_data" SERVICE_REFRESH_VEHICLE_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_VIN): cv.string, } ) SERVICE_EXECUTE_VEHICLE_ACTION = "execute_vehicle_action" SERVICE_EXECUTE_VEHICLE_ACTION_SCHEMA = vol.Schema( {vol.Required(CONF_VIN): cv.string, vol.Required(CONF_ACTION): cv.string} ) SERVICE_START_CLIMATE_CONTROL = "start_climate_control" SERVICE_START_CLIMATE_CONTROL_SCHEMA = vol.Schema( { vol.Required(CONF_VIN): cv.string, vol.Optional(CONF_CLIMATE_TEMP_F): cv.positive_int, vol.Optional(CONF_CLIMATE_TEMP_C): cv.positive_int, vol.Optional(CONF_CLIMATE_GLASS): cv.boolean, vol.Optional(CONF_CLIMATE_SEAT_FL): cv.boolean, vol.Optional(CONF_CLIMATE_SEAT_FR): cv.boolean, vol.Optional(CONF_CLIMATE_SEAT_RL): cv.boolean, vol.Optional(CONF_CLIMATE_SEAT_RR): cv.boolean, vol.Optional(CONF_CLIMATE_AT_UNLOCK): cv.boolean, vol.Optional(CONF_CLIMATE_MODE): cv.string, } ) SERVICE_START_AUXILIARY_HEATING = "start_auxiliary_heating" SERVICE_START_AUXILIARY_HEATING_SCHEMA = vol.Schema( { vol.Required(CONF_VIN): cv.string, vol.Optional(CONF_DURATION): cv.positive_int, } ) SERVICE_SET_TARGET_SOC = "set_target_soc" SERVICE_SET_TARGET_SOC_SCHEMA = vol.Schema( { vol.Required(CONF_VIN): cv.string, vol.Required(CONF_TARGET_SOC): vol.All( cv.positive_int, vol.Range(min=20, max=100) ), } ) PLATFORMS: list[str] = [ Platform.BINARY_SENSOR, Platform.SENSOR, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SWITCH, ] SERVICE_REFRESH_CLOUD_DATA = "refresh_cloud_data" _LOGGER = logging.getLogger(__name__) class AudiAccount(AudiConnectObserver): def __init__(self, hass, config_entry): """Initialize the component state.""" self.hass = hass self.config_entry = config_entry self.config_vehicles = set() self.vehicles = set() def init_connection(self): session = async_get_clientsession(self.hass) excluded_vins = [ x.strip() for x in self.config_entry.options.get( CONF_FILTER_VINS, self.config_entry.data.get(CONF_FILTER_VINS, "") ).split(",") if x.strip() ] self.connection = AudiConnectAccount( session=session, username=self.config_entry.data.get(CONF_USERNAME), password=self.config_entry.data.get(CONF_PASSWORD), country=self.config_entry.data.get(CONF_REGION), spin=self.config_entry.data.get(CONF_SPIN), api_level=self.config_entry.options.get( CONF_API_LEVEL, self.config_entry.data.get( CONF_API_LEVEL, API_LEVELS[DEFAULT_API_LEVEL] ), ), excluded_vins=excluded_vins, ) self.hass.services.async_register( DOMAIN, SERVICE_REFRESH_VEHICLE_DATA, self.refresh_vehicle_data, schema=SERVICE_REFRESH_VEHICLE_DATA_SCHEMA, ) self.hass.services.async_register( DOMAIN, SERVICE_EXECUTE_VEHICLE_ACTION, self.execute_vehicle_action, schema=SERVICE_EXECUTE_VEHICLE_ACTION_SCHEMA, ) self.hass.services.async_register( DOMAIN, SERVICE_START_CLIMATE_CONTROL, self.start_climate_control, schema=SERVICE_START_CLIMATE_CONTROL_SCHEMA, ) self.hass.services.async_register( DOMAIN, SERVICE_REFRESH_CLOUD_DATA, self.update, ) self.hass.services.async_register( DOMAIN, SERVICE_START_AUXILIARY_HEATING, self.start_auxiliary_heating, schema=SERVICE_START_AUXILIARY_HEATING_SCHEMA, ) self.hass.services.async_register( DOMAIN, SERVICE_SET_TARGET_SOC, self.set_target_soc, schema=SERVICE_SET_TARGET_SOC_SCHEMA, ) self.connection.add_observer(self) def is_enabled(self, attr): return True # """Return true if the user has enabled the resource.""" # return attr in config[DOMAIN].get(CONF_RESOURCES, [attr]) async def discover_vehicles(self, vehicles): if len(vehicles) > 0: for vehicle in vehicles: vin = vehicle.vin.lower() self.vehicles.add(vin) cfg_vehicle = VehicleData(self.config_entry) cfg_vehicle.vehicle = vehicle self.config_vehicles.add(cfg_vehicle) dashboard = Dashboard(self.connection, vehicle) for instrument in ( instrument for instrument in dashboard.instruments if instrument._component in COMPONENTS and self.is_enabled(instrument.slug_attr) ): if instrument._component == "sensor": cfg_vehicle.sensors.add(instrument) if instrument._component == "binary_sensor": cfg_vehicle.binary_sensors.add(instrument) if instrument._component == "switch": cfg_vehicle.switches.add(instrument) if instrument._component == "device_tracker": cfg_vehicle.device_trackers.add(instrument) if instrument._component == "lock": cfg_vehicle.locks.add(instrument) await self.hass.config_entries.async_forward_entry_setups( self.config_entry, PLATFORMS ) async def update(self, now): """Update status from the cloud.""" _LOGGER.debug("Starting refresh cloud data...") if not await self.connection.update(None): _LOGGER.warning("Failed refresh cloud data") return False # Discover new vehicles that have not been added yet new_vehicles = [ x for x in self.connection._vehicles if x.vin not in self.vehicles ] if new_vehicles: _LOGGER.debug("Retrieved %d vehicle(s)", len(new_vehicles)) await self.discover_vehicles(new_vehicles) async_dispatcher_send(self.hass, SIGNAL_STATE_UPDATED) for config_vehicle in self.config_vehicles: for instrument in config_vehicle.device_trackers: async_dispatcher_send(self.hass, TRACKER_UPDATE, instrument) _LOGGER.debug("Successfully refreshed cloud data") return True async def execute_vehicle_action(self, service): vin = service.data.get(CONF_VIN).lower() action = service.data.get(CONF_ACTION).lower() if action == "lock": await self.connection.set_vehicle_lock(vin, True) if action == "unlock": await self.connection.set_vehicle_lock(vin, False) if action == "start_climatisation": await self.connection.set_vehicle_climatisation(vin, True) if action == "stop_climatisation": await self.connection.set_vehicle_climatisation(vin, False) if action == "start_charger": await self.connection.set_battery_charger(vin, True, False) if action == "start_timed_charger": await self.connection.set_battery_charger(vin, True, True) if action == "stop_charger": await self.connection.set_battery_charger(vin, False, False) if action == "start_preheater": _LOGGER.warning( 'The "Start Preheater (Legacy)" action is deprecated and will be removed in a future release.' 'Please use the "Start Auxiliary Heating" service instead.' ) await self.connection.set_vehicle_pre_heater(vin, True) if action == "stop_preheater": await self.connection.set_vehicle_pre_heater(vin, False) if action == "start_window_heating": await self.connection.set_vehicle_window_heating(vin, True) if action == "stop_window_heating": await self.connection.set_vehicle_window_heating(vin, False) async def start_climate_control(self, service): _LOGGER.debug("Initiating Start Climate Control Service...") vin = service.data.get(CONF_VIN).lower() # Optional Parameters temp_f = service.data.get(CONF_CLIMATE_TEMP_F, None) temp_c = service.data.get(CONF_CLIMATE_TEMP_C, None) glass_heating = service.data.get(CONF_CLIMATE_GLASS, False) seat_fl = service.data.get(CONF_CLIMATE_SEAT_FL, False) seat_fr = service.data.get(CONF_CLIMATE_SEAT_FR, False) seat_rl = service.data.get(CONF_CLIMATE_SEAT_RL, False) seat_rr = service.data.get(CONF_CLIMATE_SEAT_RR, False) climatisation_at_unlock = service.data.get(CONF_CLIMATE_AT_UNLOCK, False) climatisation_mode = service.data.get(CONF_CLIMATE_MODE) await self.connection.start_climate_control( vin, temp_f, temp_c, glass_heating, seat_fl, seat_fr, seat_rl, seat_rr, climatisation_at_unlock, climatisation_mode, ) async def start_auxiliary_heating(self, service): vin = service.data.get(CONF_VIN) # Optional Parameters duration = service.data.get(CONF_DURATION, None) if duration is None: _LOGGER.debug('Initiating "Start Auxiliary Heating" action...') else: _LOGGER.debug( f'Initiating "Start Auxiliary Heating" action for {duration} minutes...' ) await self.connection.set_vehicle_pre_heater( vin=vin, activate=True, duration=duration, ) async def set_target_soc(self, service): """Set the target state of charge for the vehicle battery.""" vin = service.data.get(CONF_VIN).lower() target_soc = service.data.get(CONF_TARGET_SOC) _LOGGER.debug( f"Initiating 'Set Target SOC' action to {target_soc}% for VIN {vin}..." ) await self.connection.set_target_state_of_charge(vin, target_soc) async def handle_notification(self, vin: str, action: str) -> None: await self._refresh_vehicle_data(vin) async def refresh_vehicle_data(self, service): vin = service.data.get(CONF_VIN).lower() await self._refresh_vehicle_data(vin) async def _refresh_vehicle_data(self, vin): redacted_vin = "*" * (len(vin) - 4) + vin[-4:] res = await self.connection.refresh_vehicle_data(vin) if res is True: _LOGGER.debug("Refresh vehicle data successful for VIN: %s", redacted_vin) self.hass.bus.fire( "{}_{}".format(DOMAIN, REFRESH_VEHICLE_DATA_COMPLETED_EVENT), {"vin": redacted_vin}, ) elif res == "disabled": _LOGGER.debug("Refresh vehicle data is disabled for VIN: %s", redacted_vin) else: _LOGGER.debug("Refresh vehicle data failed for VIN: %s", redacted_vin) self.hass.bus.fire( "{}_{}".format(DOMAIN, REFRESH_VEHICLE_DATA_FAILED_EVENT), {"vin": redacted_vin}, ) _LOGGER.debug("Requesting to refresh cloud data in %d seconds...", UPDATE_SLEEP) await asyncio.sleep(UPDATE_SLEEP) try: _LOGGER.debug("Requesting to refresh cloud data now...") await self.update(utcnow()) except Exception as e: _LOGGER.exception("Refresh cloud data failed: %s", str(e)) ================================================ FILE: custom_components/audiconnect/audi_api.py ================================================ import json import logging from datetime import datetime import asyncio from asyncio import TimeoutError, CancelledError from aiohttp import ClientResponseError from aiohttp.hdrs import METH_GET, METH_POST, METH_PUT from typing import Dict # =========================================== # VERBOSE DEBUG TOGGLE # Set to True to log EVERYTHING (full headers, full body, raw JSON, etc.) # Set to False for normal operation (minimal debug output) DEBUG_VERBOSE = False # =========================================== TIMEOUT = 30 _LOGGER = logging.getLogger(__name__) class AudiAPI: HDR_XAPP_VERSION = "4.31.0" HDR_USER_AGENT = "Android/4.31.0 (Build 800341641.root project 'myaudi_android'.ext.buildTime) Android/13" def __init__(self, session, proxy=None): self.__token = None self.__xclientid = None self._session = session self.__proxy = {"http": proxy, "https": proxy} if proxy else None def use_token(self, token): self.__token = token if DEBUG_VERBOSE: _LOGGER.debug("[use_token] Token set: %s", token) def set_xclient_id(self, xclientid): self.__xclientid = xclientid if DEBUG_VERBOSE: _LOGGER.debug("[set_xclient_id] X-Client-ID set: %s", xclientid) async def request( self, method, url, data, headers: Dict[str, str] = None, raw_reply: bool = False, raw_contents: bool = False, rsp_wtxt: bool = False, **kwargs, ): if DEBUG_VERBOSE: _LOGGER.debug("[REQUEST INITIATED]") _LOGGER.debug("Method: %s", method) _LOGGER.debug("URL: %s", url) _LOGGER.debug("Data: %s", data) _LOGGER.debug("Headers: %s", headers) _LOGGER.debug("Kwargs: %s", kwargs) _LOGGER.debug("Proxy: %s", self.__proxy) try: async with asyncio.timeout(TIMEOUT): async with self._session.request( method, url, headers=headers, data=data, **kwargs ) as response: if DEBUG_VERBOSE: _LOGGER.debug("[RESPONSE RECEIVED]") _LOGGER.debug("Status: %s", response.status) _LOGGER.debug("Reason: %s", response.reason) _LOGGER.debug("Headers: %s", dict(response.headers)) if raw_reply: if DEBUG_VERBOSE: _LOGGER.debug("Returning raw reply object.") return response if rsp_wtxt: txt = await response.text() if DEBUG_VERBOSE: _LOGGER.debug("Response text (full): %s", txt) else: _LOGGER.debug( "Returning response text; length=%d", len(txt) ) return response, txt elif raw_contents: contents = await response.read() if DEBUG_VERBOSE: _LOGGER.debug("Raw contents (bytes): %s", contents) else: _LOGGER.debug( "Returning raw contents; length=%d", len(contents) ) return contents elif response.status in (200, 202, 207): raw_body = await response.text() if DEBUG_VERBOSE: _LOGGER.debug( "Raw JSON text (before parsing): %s", raw_body ) json_data = json_loads(raw_body) if DEBUG_VERBOSE: _LOGGER.debug("Parsed JSON data (full): %s", json_data) else: _LOGGER.debug("Returning JSON data: %s", json_data) return json_data else: # this should be refactored: # 204 is a valid response for some requests (e.g. update_vehicle_position) # and should not raise an error. # request should return a tuple indicating the response itself and the # http-status if response.status != 204: _LOGGER.debug( "Non-success response: status=%s, reason=%s — will be handled by caller", response.status, response.reason, ) if DEBUG_VERBOSE: _LOGGER.debug( "Response url: %s, body: %s", url, await response.text(), ) raise ClientResponseError( response.request_info, response.history, status=response.status, message=response.reason, ) except CancelledError: if DEBUG_VERBOSE: _LOGGER.debug("Request cancelled (CancelledError).") raise TimeoutError("Timeout error") except TimeoutError: if DEBUG_VERBOSE: _LOGGER.debug("Request timed out after %s seconds.", TIMEOUT) raise TimeoutError("Timeout error") except Exception as e: if DEBUG_VERBOSE: _LOGGER.exception("Unexpected exception during request: %s", e) raise async def get( self, url, raw_reply: bool = False, raw_contents: bool = False, **kwargs ): full_headers = self.__get_headers() if DEBUG_VERBOSE: _LOGGER.debug("[GET] URL: %s | Headers: %s", url, full_headers) return await self.request( METH_GET, url, data=None, headers=full_headers, raw_reply=raw_reply, raw_contents=raw_contents, **kwargs, ) async def put(self, url, data=None, headers: Dict[str, str] = None): full_headers = self.__get_headers() if headers: full_headers.update(headers) if DEBUG_VERBOSE: _LOGGER.debug( "[PUT] URL: %s | Data: %s | Headers: %s", url, data, full_headers ) return await self.request(METH_PUT, url, headers=full_headers, data=data) async def post( self, url, data=None, headers: Dict[str, str] = None, use_json: bool = True, raw_reply: bool = False, raw_contents: bool = False, **kwargs, ): full_headers = self.__get_headers() if headers: full_headers.update(headers) if use_json and data is not None: data = json.dumps(data) if DEBUG_VERBOSE: _LOGGER.debug( "[POST] URL: %s | Data: %s | Headers: %s", url, data, full_headers ) return await self.request( METH_POST, url, headers=full_headers, data=data, raw_reply=raw_reply, raw_contents=raw_contents, **kwargs, ) def __get_headers(self): data = { "Accept": "application/json", "Accept-Charset": "utf-8", "X-App-Version": self.HDR_XAPP_VERSION, "X-App-Name": "myAudi", "User-Agent": self.HDR_USER_AGENT, } if self.__token is not None: data["Authorization"] = "Bearer " + self.__token.get("access_token") if self.__xclientid is not None: data["X-Client-ID"] = self.__xclientid if DEBUG_VERBOSE: _LOGGER.debug("[HEADERS BUILT]: %s", data) return data def obj_parser(obj): """Parse datetime.""" for key, val in obj.items(): try: obj[key] = datetime.strptime(val, "%Y-%m-%dT%H:%M:%S%z") except (TypeError, ValueError): pass return obj def json_loads(s): return json.loads(s, object_hook=obj_parser) ================================================ FILE: custom_components/audiconnect/audi_connect_account.py ================================================ import time from datetime import datetime, timezone, timedelta import logging import asyncio from typing import List import re from asyncio import TimeoutError from aiohttp import ClientResponseError from abc import ABC, abstractmethod from .audi_services import AudiService from .audi_api import AudiAPI from .util import log_exception, get_attr, parse_int, parse_float, parse_datetime _LOGGER = logging.getLogger(__name__) MAX_RESPONSE_ATTEMPTS = 10 REQUEST_STATUS_SLEEP = 5 ACTION_LOCK = "lock" ACTION_CLIMATISATION = "climatisation" ACTION_CHARGER = "charger" ACTION_WINDOW_HEATING = "window_heating" ACTION_PRE_HEATER = "pre_heater" class AudiConnectObserver(ABC): @abstractmethod async def handle_notification(self, vin: str, action: str) -> None: pass class AudiConnectAccount: """Representation of an Audi Connect Account.""" def __init__( self, session, username: str, password: str, country: str, spin: str, api_level: int, excluded_vins: List[str] = None, ) -> None: self._api = AudiAPI(session) self._audi_service = AudiService(self._api, country, spin, api_level) self._username = username self._password = password self._loggedin = False self._support_vehicle_refresh = True self._logintime = 0 self._connect_retries = 3 self._connect_delay = 10 self._update_listeners = [] self._vehicles = [] self._audi_vehicles = [] self._excluded_vins = [v.lower() for v in (excluded_vins or [])] self._observers: List[AudiConnectObserver] = [] def add_observer(self, observer: AudiConnectObserver) -> None: self._observers.append(observer) async def notify(self, vin: str, action: str) -> None: for observer in self._observers: await observer.handle_notification(vin, action) async def login(self): for i in range(self._connect_retries): self._loggedin = await self.try_login(i == self._connect_retries - 1) if self._loggedin is True: self._logintime = time.time() break if i < self._connect_retries - 1: _LOGGER.warning( "LOGIN: Login to Audi service failed, retrying in %s seconds", self._connect_delay, ) await asyncio.sleep(self._connect_delay) async def try_login(self, logError): try: _LOGGER.debug("LOGIN: Requesting login to Audi service...") await self._audi_service.login(self._username, self._password, False) _LOGGER.debug("LOGIN: Login to Audi service successful") return True except Exception as exception: if logError is True: _LOGGER.error( "LOGIN: Failed to log in to the Audi service: %s." "You may need to open the myAudi app, or log in via a web browser, to accept updated terms and conditions.", str(exception), ) return False async def update(self, vinlist): if not self._loggedin: await self.login() if not self._loggedin: return False # elapsed_sec = time.time() - self._logintime if await self._audi_service.refresh_token_if_necessary(elapsed_sec): # Store current timestamp when refresh was performed and successful self._logintime = time.time() """Update the state of all vehicles.""" try: if len(self._audi_vehicles) > 0: for vehicle in self._audi_vehicles: if vehicle.vin and vehicle.vin.lower() in self._excluded_vins: continue await self.add_or_update_vehicle(vehicle, vinlist) else: vehicles_response = await self._audi_service.get_vehicle_information() self._audi_vehicles = vehicles_response.vehicles self._vehicles = [] for vehicle in self._audi_vehicles: if vehicle.vin and vehicle.vin.lower() in self._excluded_vins: _LOGGER.debug("Skipping excluded vehicle VIN: %s", vehicle.vin) continue await self.add_or_update_vehicle(vehicle, vinlist) for listener in self._update_listeners: listener() # TR/2021-12-01: do not set to False as refresh_token is used # self._loggedin = False return True except OSError as exception: # Force a re-login in case of failure/exception self._loggedin = False _LOGGER.exception(exception) return False async def add_or_update_vehicle(self, vehicle, vinlist): if vehicle.vin is not None: if vinlist is None or vehicle.vin.lower() in vinlist: vupd = [x for x in self._vehicles if x.vin == vehicle.vin.lower()] if len(vupd) > 0: if await vupd[0].update() is False: self._loggedin = False else: try: audiVehicle = AudiConnectVehicle(self._audi_service, vehicle) if await audiVehicle.update() is False: self._loggedin = False self._vehicles.append(audiVehicle) except Exception: pass async def refresh_vehicle_data(self, vin: str): redacted_vin = "*" * (len(vin) - 4) + vin[-4:] if not self._loggedin: await self.login() if not self._loggedin: return False if not self._support_vehicle_refresh: _LOGGER.debug( "Vehicle refresh support is disabled for VIN: %s. Exiting update process.", redacted_vin, ) return "disabled" try: _LOGGER.debug( "Sending command to refresh vehicle data for VIN: %s", redacted_vin, ) await self._audi_service.refresh_vehicle_data(vin) _LOGGER.debug( "Successfully refreshed vehicle data for VIN: %s", redacted_vin, ) return True except TimeoutError: _LOGGER.debug( "TimeoutError encountered while refreshing vehicle data for VIN: %s.", redacted_vin, ) return False except ClientResponseError as cre: if cre.status in (403, 404): _LOGGER.debug( "VEHICLE REFRESH: ClientResponseError with status %s for VIN: %s. Vehicle does not support vehicle refresh — disabling.", cre.status, redacted_vin, ) self._support_vehicle_refresh = False return "disabled" elif cre.status == 502: _LOGGER.debug( "VEHICLE REFRESH: Received status %s while refreshing vehicle data for VIN: %s. This is typically transient and may resolve on its own.", cre.status, redacted_vin, ) return False elif cre.status != 204: _LOGGER.debug( "VEHICLE REFRESH: ClientResponseError with status %s while refreshing vehicle data for VIN: %s. Error: %s", cre.status, redacted_vin, cre, ) return False else: _LOGGER.debug( "VEHICLE REFRESH: Refresh vehicle data currently not available for VIN: %s. Received 204 status.", redacted_vin, ) return False except Exception as e: _LOGGER.error( "VEHICLE REFRESH: An unexpected error occurred while refreshing vehicle data for VIN: %s. Error: %s", redacted_vin, e, ) return False async def set_vehicle_lock(self, vin: str, lock: bool): if not self._loggedin: await self.login() if not self._loggedin: return False try: _LOGGER.debug( "Sending command to {action} to vehicle {vin}".format( action="lock" if lock else "unlock", vin=vin ), ) await self._audi_service.set_vehicle_lock(vin, lock) _LOGGER.debug( "Successfully {action} vehicle {vin}".format( action="locked" if lock else "unlocked", vin=vin ), ) await self.notify(vin, ACTION_LOCK) return True except Exception as exception: log_exception( exception, "Unable to {action} {vin}".format( action="lock" if lock else "unlock", vin=vin ), ) async def set_target_state_of_charge(self, vin: str, target_soc: int): """Set the target state of charge for the vehicle battery.""" if not self._loggedin: await self.login() if not self._loggedin: return False try: _LOGGER.debug( "Setting target state of charge to %d%% for vehicle %s", target_soc, vin, ) await self._audi_service.set_target_state_of_charge(vin, target_soc) _LOGGER.debug( "Successfully set target state of charge to %d%% for vehicle %s", target_soc, vin, ) return True except Exception as exception: log_exception( exception, "Unable to set target state of charge for vehicle {}".format(vin), ) return False async def set_vehicle_climatisation(self, vin: str, activate: bool): if not self._loggedin: await self.login() if not self._loggedin: return False try: _LOGGER.debug( "Sending command to {action} climatisation to vehicle {vin}".format( action="start" if activate else "stop", vin=vin ), ) await self._audi_service.set_climatisation(vin, activate) _LOGGER.debug( "Successfully {action} climatisation of vehicle {vin}".format( action="started" if activate else "stopped", vin=vin ), ) await self.notify(vin, ACTION_CLIMATISATION) return True except Exception as exception: log_exception( exception, "Unable to {action} climatisation of vehicle {vin}".format( action="start" if activate else "stop", vin=vin ), ) async def start_climate_control( self, vin: str, temp_f: int, temp_c: int, glass_heating: bool, seat_fl: bool, seat_fr: bool, seat_rl: bool, seat_rr: bool, climatisation_at_unlock: bool, climatisation_mode: str, ): if not self._loggedin: await self.login() if not self._loggedin: return False try: _LOGGER.debug( f"Sending command to start climate control for vehicle {vin} with settings - Temp(F): {temp_f}, Temp(C): {temp_c}, Glass Heating: {glass_heating}, Seat FL: {seat_fl}, Seat FR: {seat_fr}, Seat RL: {seat_rl}, Seat RR: {seat_rr}, Climatisation at Unlock: {climatisation_at_unlock}, Climatisation Mode: {climatisation_mode}" ) await self._audi_service.start_climate_control( vin, temp_f, temp_c, glass_heating, seat_fl, seat_fr, seat_rl, seat_rr, climatisation_at_unlock, climatisation_mode, ) _LOGGER.debug(f"Successfully started climate control of vehicle {vin}") await self.notify(vin, ACTION_CLIMATISATION) return True except Exception as exception: _LOGGER.error( f"Unable to start climate control of vehicle {vin}. Error: {exception}", exc_info=True, ) return False async def set_battery_charger(self, vin: str, activate: bool, timer: bool): if not self._loggedin: await self.login() if not self._loggedin: return False try: _LOGGER.debug( "Sending command to {action}{timer} charger to vehicle {vin}".format( action="start" if activate else "stop", vin=vin, timer=" timed" if timer else "", ), ) await self._audi_service.set_battery_charger(vin, activate, timer) _LOGGER.debug( "Successfully {action}{timer} charger of vehicle {vin}".format( action="started" if activate else "stopped", vin=vin, timer=" timed" if timer else "", ), ) await self.notify(vin, ACTION_CHARGER) return True except Exception as exception: log_exception( exception, "Unable to {action} charger of vehicle {vin}".format( action="start" if activate else "stop", vin=vin ), ) async def set_vehicle_window_heating(self, vin: str, activate: bool): if not self._loggedin: await self.login() if not self._loggedin: return False try: _LOGGER.debug( "Sending command to {action} window heating to vehicle {vin}".format( action="start" if activate else "stop", vin=vin ), ) await self._audi_service.set_window_heating(vin, activate) _LOGGER.debug( "Successfully {action} window heating of vehicle {vin}".format( action="started" if activate else "stopped", vin=vin ), ) await self.notify(vin, ACTION_WINDOW_HEATING) return True except Exception as exception: log_exception( exception, "Unable to {action} window heating of vehicle {vin}".format( action="start" if activate else "stop", vin=vin ), ) async def set_vehicle_pre_heater(self, vin: str, activate: bool, **kwargs): if not self._loggedin: await self.login() if not self._loggedin: return False try: _LOGGER.debug( "Sending command to {action} pre-heater to vehicle {vin}".format( action="start" if activate else "stop", vin=vin ), ) # Pass **kwargs down await self._audi_service.set_pre_heater(vin, activate, **kwargs) _LOGGER.debug( "Successfully {action} pre-heater of vehicle {vin}".format( action="started" if activate else "stopped", vin=vin ), ) await self.notify(vin, ACTION_PRE_HEATER) return True except Exception as exception: log_exception( exception, "Unable to {action} pre-heater of vehicle {vin}".format( action="start" if activate else "stop", vin=vin ), ) class AudiConnectVehicle: def __init__(self, audi_service: AudiService, vehicle) -> None: self._audi_service = audi_service self._vehicle = vehicle self._vin = vehicle.vin.lower() self._vehicle.state = {} self._vehicle.fields = {} self._logged_errors = set() self._no_error = False self.support_status_report = True self.support_position = True self.support_climater = True self.support_preheater = True self.support_charger = True self.support_trip_data = True self.charging_complete_time_frozen = None @property def vin(self): return self._vin @property def csid(self): return self._vehicle.csid @property def title(self): return self._vehicle.title @property def model(self): return self._vehicle.model @property def model_year(self): return self._vehicle.model_year @property def model_family(self): return self._vehicle.model_family async def call_update(self, func, ntries: int): try: await func() except TimeoutError: if ntries > 1: await asyncio.sleep(2) await self.call_update(func, ntries - 1) else: raise async def update(self): info = "" try: self._no_error = True info = "statusreport" await self.call_update(self.update_vehicle_statusreport, 3) info = "shortterm" await self.call_update(self.update_vehicle_shortterm, 3) info = "longterm" await self.call_update(self.update_vehicle_longterm, 3) info = "position" await self.call_update(self.update_vehicle_position, 3) info = "climater" await self.call_update(self.update_vehicle_climater, 3) # info = "charger" # await self.call_update(self.update_vehicle_charger, 3) info = "preheater" await self.call_update(self.update_vehicle_preheater, 3) # Return True on success, False on error return self._no_error except Exception as exception: log_exception( exception, "Unable to update vehicle data {} of {}".format( info, self._vehicle.vin ), ) def log_exception_once(self, exception, message): self._no_error = False err = message + ": " + str(exception).rstrip("\n") if err not in self._logged_errors: self._logged_errors.add(err) _LOGGER.error(err, exc_info=True) async def update_vehicle_statusreport(self): if not self.support_status_report: return try: status = await self._audi_service.get_stored_vehicle_data(self._vehicle.vin) self._vehicle.fields = { status.data_fields[i].name: status.data_fields[i].value for i in range(len(status.data_fields)) } # Initialize with a default very old datetime self._vehicle.state["last_update_time"] = datetime( 1970, 1, 1, tzinfo=timezone.utc ) # Update with the newest carCapturedTimestamp from data_fields for f in status.data_fields: new_time = parse_datetime(f.measure_time) if new_time: self._vehicle.state["last_update_time"] = max( self._vehicle.state["last_update_time"], new_time ) # Update with the newest carCapturedTimestamp from states for state in status.states: new_time = parse_datetime(state.get("measure_time")) if new_time: self._vehicle.state["last_update_time"] = max( self._vehicle.state["last_update_time"], new_time ) # Update other states for state in status.states: self._vehicle.state[state["name"]] = state["value"] except TimeoutError: raise except ClientResponseError as resp_exception: if resp_exception.status in (403, 404): self.support_status_report = False else: self.log_exception_once( resp_exception, "Unable to obtain the vehicle status report of {}".format( self._vehicle.vin ), ) except Exception as exception: self.log_exception_once( exception, "Unable to obtain the vehicle status report of {}".format( self._vehicle.vin ), ) async def update_vehicle_position(self): # Redact all but the last 4 characters of the VIN redacted_vin = "*" * (len(self._vehicle.vin) - 4) + self._vehicle.vin[-4:] _LOGGER.debug( "POSITION: Starting update_vehicle_position for VIN: %s", redacted_vin ) if not self.support_position: _LOGGER.debug( "POSITION: Vehicle position support is disabled for VIN: %s. Exiting update process.", redacted_vin, ) return try: _LOGGER.debug( "POSITION: Attempting to retrieve stored vehicle position for VIN: %s", redacted_vin, ) resp = await self._audi_service.get_stored_position(self._vehicle.vin) # To enable detailed logging of raw vehicle position data for debugging purposes: # 1. Remove the '#' from the start of the _LOGGER.debug line below. # 2. Save the file. # 3. Restart Home Assistant to apply the changes. # Note: This will log sensitive data. To stop logging this data: # 1. Add the '#' back at the start of the _LOGGER.debug line. # 2. Save the file and restart Home Assistant again. # _LOGGER.debug("POSITION - UNREDACTED SENSITIVE DATA: Raw vehicle position data: %s", resp) if resp is not None: redacted_lat = re.sub(r"\d", "#", str(resp["data"]["lat"])) redacted_lon = re.sub(r"\d", "#", str(resp["data"]["lon"])) # Check if 'carCapturedTimestamp' is available in the data if "carCapturedTimestamp" in resp["data"]: timestamp = parse_datetime(resp["data"]["carCapturedTimestamp"]) parktime = parse_datetime(resp["data"]["carCapturedTimestamp"]) else: # Log and use None timestamp and parktime timestamp = None parktime = None _LOGGER.debug( "POSITION: Timestamp not available for vehicle position data of VIN: %s.", redacted_vin, ) _LOGGER.debug( "POSITION: Vehicle position data received for VIN: %s, lat: %s, lon: %s, timestamp: %s, parktime: %s", redacted_vin, redacted_lat, redacted_lon, timestamp, parktime, ) self._vehicle.state["position"] = { "latitude": resp["data"]["lat"], "longitude": resp["data"]["lon"], "timestamp": timestamp, "parktime": parktime, } self._vehicle.state["is_moving"] = False _LOGGER.debug( "POSITION: Vehicle position updated successfully for VIN: %s", redacted_vin, ) else: _LOGGER.warning( "POSITION: No vehicle position data received for VIN: %s. Response was None.", redacted_vin, ) except TimeoutError: _LOGGER.warning( "POSITION: TimeoutError encountered while updating vehicle position for VIN: %s.", redacted_vin, ) raise except ClientResponseError as cre: if cre.status in (403, 404): _LOGGER.debug( "POSITION: ClientResponseError with status %s for VIN: %s. Vehicle does not support position — disabling.", cre.status, redacted_vin, ) self.support_position = False elif cre.status == 502: _LOGGER.debug( "POSITION: Received status %s while updating vehicle position for VIN: %s. This is typically transient and may resolve on its own.", cre.status, redacted_vin, ) elif cre.status != 204: _LOGGER.warning( "POSITION: ClientResponseError with status %s for VIN: %s. Error: %s", cre.status, redacted_vin, cre, ) else: _LOGGER.debug( "POSITION: Vehicle position currently not available for VIN: %s (Is moving?!). Received 204 status.", redacted_vin, ) # we receive a 204 when the vehicle is moving. self._vehicle.state["is_moving"] = True except Exception as e: _LOGGER.error( "POSITION: An unexpected error occurred while updating vehicle position for VIN: %s. Error: %s", redacted_vin, e, ) async def update_vehicle_climater(self): redacted_vin = "*" * (len(self._vehicle.vin) - 4) + self._vehicle.vin[-4:] if not self.support_climater: return try: result = await self._audi_service.get_climater(self._vehicle.vin) if result: self._vehicle.state["climatisationState"] = get_attr( result, "climater.status.climatisationStatusData.climatisationState.content", ) tmp = get_attr( result, "climater.status.temperatureStatusData.outdoorTemperature.content", ) if tmp is not None: self._vehicle.state["outdoorTemperature"] = round( float(tmp) / 10 - 273, 1 ) else: self._vehicle.state["outdoorTemperature"] = None remainingClimatisationTime = get_attr( result, "climater.status.climatisationStatusData.remainingClimatisationTime.content", ) self._vehicle.state["remainingClimatisationTime"] = ( remainingClimatisationTime ) _LOGGER.debug( "CLIMATER: remainingClimatisationTime: %s", remainingClimatisationTime, ) vehicleParkingClock = get_attr( result, "climater.status.vehicleParkingClockStatusData.vehicleParkingClock.content", ) self._vehicle.state["vehicleParkingClock"] = parse_datetime( vehicleParkingClock ) _LOGGER.debug("CLIMATER: vehicleParkingClock: %s", vehicleParkingClock) isMirrorHeatingActive = get_attr( result, "climater.status.climatisationStatusData.climatisationElementStates.isMirrorHeatingActive.content", ) self._vehicle.state["isMirrorHeatingActive"] = isMirrorHeatingActive _LOGGER.debug( "CLIMATER: isMirrorHeatingActive: %s", isMirrorHeatingActive ) else: _LOGGER.debug( "No climater data received for VIN: %s. Response was None.", redacted_vin, ) except TimeoutError: _LOGGER.debug( "TimeoutError encountered while updating climater for VIN: %s.", redacted_vin, ) raise except ClientResponseError as cre: if cre.status in (403, 404): _LOGGER.debug( "CLIMATER: ClientResponseError with status %s for VIN: %s. Vehicle does not support climater — disabling.", cre.status, redacted_vin, ) self.support_climater = False elif cre.status == 502: _LOGGER.debug( "CLIMATER: Received status %s while updating climater for VIN: %s. This is typically transient and may resolve on its own.", cre.status, redacted_vin, ) elif cre.status != 204: _LOGGER.debug( "ClientResponseError with status %s while updating climater for VIN: %s. Error: %s", cre.status, redacted_vin, cre, ) else: _LOGGER.debug( "Climater currently not available for VIN: %s. Received 204 status.", redacted_vin, ) except Exception as e: _LOGGER.error( "An unexpected error occurred while updating climater for VIN: %s. Error: %s", redacted_vin, e, ) async def update_vehicle_preheater(self): redacted_vin = "*" * (len(self._vehicle.vin) - 4) + self._vehicle.vin[-4:] if not self.support_preheater: return try: result = await self._audi_service.get_preheater(self._vehicle.vin) if result: self._vehicle.state["preheaterState"] = get_attr( result, "statusResponse", ) except TimeoutError: raise except ClientResponseError as cre: if cre.status in (403, 404, 502): _LOGGER.debug( "PREHEATER: ClientResponseError with status %s for VIN: %s. Vehicle does not support preheater — disabling.", cre.status, redacted_vin, ) self.support_preheater = False # elif cre.status == 502: # _LOGGER.warning( # "PREHEATER: ClientResponseError with status %s while updating preheater for VIN: %s. This issue may resolve in time. If it persists, please open an issue.", # cre.status, # redacted_vin, # ) else: self.log_exception_once( cre, "Unable to obtain the vehicle preheater state for {}".format( self._vehicle.vin ), ) except Exception as exception: self.log_exception_once( exception, "Unable to obtain the vehicle preheater state for {}".format( self._vehicle.vin ), ) async def update_vehicle_charger(self): redacted_vin = "*" * (len(self._vehicle.vin) - 4) + self._vehicle.vin[-4:] if not self.support_charger: return try: result = await self._audi_service.get_charger(self._vehicle.vin) if result: self._vehicle.state["maxChargeCurrent"] = get_attr( result, "charger.settings.maxChargeCurrent.content" ) self._vehicle.state["chargingState"] = get_attr( result, "charger.status.chargingStatusData.chargingState.content" ) self._vehicle.state["actualChargeRate"] = get_attr( result, "charger.status.chargingStatusData.actualChargeRate.content" ) if self._vehicle.state["actualChargeRate"] is not None: self._vehicle.state["actualChargeRate"] = float( self._vehicle.state["actualChargeRate"] ) self._vehicle.state["actualChargeRateUnit"] = get_attr( result, "charger.status.chargingStatusData.chargeRateUnit.content" ) self._vehicle.state["chargingPower"] = get_attr( result, "charger.status.chargingStatusData.chargingPower.content" ) self._vehicle.state["chargingMode"] = get_attr( result, "charger.status.chargingStatusData.chargingMode.content" ) self._vehicle.state["energyFlow"] = get_attr( result, "charger.status.chargingStatusData.energyFlow.content" ) self._vehicle.state["engineTypeFirstEngine"] = get_attr( result, "charger.status.cruisingRangeStatusData.engineTypeFirstEngine.content", ) self._vehicle.state["engineTypeSecondEngine"] = get_attr( result, "charger.status.cruisingRangeStatusData.engineTypeSecondEngine.content", ) self._vehicle.state["hybridRange"] = get_attr( result, "charger.status.cruisingRangeStatusData.hybridRange.content" ) self._vehicle.state["primaryEngineRange"] = get_attr( result, "charger.status.cruisingRangeStatusData.primaryEngineRange.content", ) self._vehicle.state["secondaryEngineRange"] = get_attr( result, "charger.status.cruisingRangeStatusData.secondaryEngineRange.content", ) self._vehicle.state["stateOfCharge"] = get_attr( result, "charger.status.batteryStatusData.stateOfCharge.content" ) self._vehicle.state["remainingChargingTime"] = get_attr( result, "charger.status.batteryStatusData.remainingChargingTime.content", ) self._vehicle.state["plugState"] = get_attr( result, "charger.status.plugStatusData.plugState.content" ) self._vehicle.state["plugLockState"] = get_attr( result, "charger.status.plugStatusData.plugLockState.content" ) self._vehicle.state["externalPower"] = get_attr( result, "charger.status.plugStatusData.externalPower.content" ) self._vehicle.state["plugledColor"] = get_attr( result, "charger.status.plugStatusData.plugledColor.content" ) except TimeoutError: raise except ClientResponseError as cre: if cre.status in (403, 404): _LOGGER.debug( "CHARGER: ClientResponseError with status %s for VIN: %s. Vehicle does not support charger — disabling.", cre.status, redacted_vin, ) self.support_charger = False elif cre.status == 502: _LOGGER.debug( "CHARGER: Received status %s while updating charger for VIN: %s. This is typically transient and may resolve on its own.", cre.status, redacted_vin, ) else: self.log_exception_once( cre, "Unable to obtain the vehicle charger state for {}".format( self._vehicle.vin ), ) except Exception as exception: self.log_exception_once( exception, "Unable to obtain the vehicle charger state for {}".format( self._vehicle.vin ), ) async def update_vehicle_longterm(self): await self.update_vehicle_tripdata("longTerm") async def update_vehicle_shortterm(self): await self.update_vehicle_tripdata("shortTerm") async def update_vehicle_tripdata(self, kind: str): redacted_vin = "*" * (len(self._vehicle.vin) - 4) + self._vehicle.vin[-4:] if not self.support_trip_data: _LOGGER.debug( "TRIP DATA: Trip data support is disabled for VIN: %s. Exiting update process.", redacted_vin, ) return try: td_cur, td_rst = await self._audi_service.get_tripdata( self._vehicle.vin, kind ) self._vehicle.state[kind.lower() + "_current"] = { "tripID": td_cur.tripID, "averageElectricEngineConsumption": td_cur.averageElectricEngineConsumption, "averageFuelConsumption": td_cur.averageFuelConsumption, "averageSpeed": td_cur.averageSpeed, "mileage": td_cur.mileage, "startMileage": td_cur.startMileage, "traveltime": td_cur.traveltime, "timestamp": td_cur.timestamp, "overallMileage": td_cur.overallMileage, "zeroEmissionDistance": td_cur.zeroEmissionDistance, } self._vehicle.state[kind.lower() + "_reset"] = { "tripID": td_rst.tripID, "averageElectricEngineConsumption": td_rst.averageElectricEngineConsumption, "averageFuelConsumption": td_rst.averageFuelConsumption, "averageSpeed": td_rst.averageSpeed, "mileage": td_rst.mileage, "startMileage": td_rst.startMileage, "traveltime": td_rst.traveltime, "timestamp": td_rst.timestamp, "overallMileage": td_rst.overallMileage, "zeroEmissionDistance": td_rst.zeroEmissionDistance, } except TimeoutError: _LOGGER.debug( "TRIP DATA: TimeoutError encountered while updating trip data for VIN: %s.", redacted_vin, ) raise except ClientResponseError as cre: if cre.status in (403, 404): _LOGGER.debug( "TRIP DATA: ClientResponseError with status %s for VIN: %s. Vehicle does not support trip data — disabling.", cre.status, redacted_vin, ) self.support_trip_data = False elif cre.status == 502: _LOGGER.debug( "TRIP DATA: Received status %s while updating trip data for VIN: %s. This is typically transient and may resolve on its own.", cre.status, redacted_vin, ) elif cre.status != 204: _LOGGER.debug( "TRIP DATA: ClientResponseError with status %s while updating trip data for VIN: %s. Error: %s", cre.status, redacted_vin, cre, ) else: _LOGGER.debug( "TRIP DATA: Trip data currently not available for VIN: %s. Received 204 status.", redacted_vin, ) except Exception as e: _LOGGER.error( "TRIP DATA: An unexpected error occurred while updating trip data for VIN: %s. Error: %s", redacted_vin, e, ) @property def last_update_time(self): if self.last_update_time_supported: return self._vehicle.state.get("last_update_time") @property def last_update_time_supported(self): check = self._vehicle.state.get("last_update_time") if check: return True @property def service_inspection_time(self): """Return time left for service inspection""" if self.service_inspection_time_supported: return int( self._vehicle.fields.get("MAINTENANCE_INTERVAL_TIME_TO_INSPECTION") ) @property def service_inspection_time_supported(self): check = self._vehicle.fields.get("MAINTENANCE_INTERVAL_TIME_TO_INSPECTION") if check and parse_int(check): return True @property def service_inspection_distance(self): """Return distance left for service inspection""" if self.service_inspection_distance_supported: return int( self._vehicle.fields.get("MAINTENANCE_INTERVAL_DISTANCE_TO_INSPECTION") ) @property def service_inspection_distance_supported(self): check = self._vehicle.fields.get("MAINTENANCE_INTERVAL_DISTANCE_TO_INSPECTION") if check and parse_int(check): return True @property def service_adblue_distance(self): """Return distance left for service inspection""" if self.service_adblue_distance_supported: return int(self._vehicle.fields.get("ADBLUE_RANGE")) @property def service_adblue_distance_supported(self): check = self._vehicle.fields.get("ADBLUE_RANGE") if check and parse_int(check): return True @property def oil_change_time(self): """Return time left for oil change""" if self.oil_change_time_supported: return int( self._vehicle.fields.get("MAINTENANCE_INTERVAL_TIME_TO_OIL_CHANGE") ) @property def oil_change_time_supported(self): check = self._vehicle.fields.get("MAINTENANCE_INTERVAL_TIME_TO_OIL_CHANGE") if check and parse_int(check): return True @property def oil_change_distance(self): """Return distance left for oil change""" if self.oil_change_distance_supported: return int( self._vehicle.fields.get("MAINTENANCE_INTERVAL_DISTANCE_TO_OIL_CHANGE") ) @property def oil_change_distance_supported(self): check = self._vehicle.fields.get("MAINTENANCE_INTERVAL_DISTANCE_TO_OIL_CHANGE") if check and parse_int(check): return True @property def oil_level(self): """Return oil level percentage""" if self.oil_level_supported: return parse_float( self._vehicle.fields.get("OIL_LEVEL_DIPSTICKS_PERCENTAGE") ) @property def oil_level_supported(self): """Check if oil level is supported.""" check = self._vehicle.fields.get("OIL_LEVEL_DIPSTICKS_PERCENTAGE") return not isinstance(check, bool) and check is not None @property def oil_level_binary(self): """Return oil level binary.""" if self.oil_level_binary_supported: return not self._vehicle.fields.get("OIL_LEVEL_DIPSTICKS_PERCENTAGE") @property def oil_level_binary_supported(self): """Check if oil level binary is supported.""" return isinstance( self._vehicle.fields.get("OIL_LEVEL_DIPSTICKS_PERCENTAGE"), bool ) @property def preheater_active(self): if self.preheater_active_supported: res = ( self._vehicle.state["preheaterState"] .get("climatisationStateReport") .get("climatisationState") ) return res != "off" @property def preheater_active_supported(self): return self.preheater_state_supported @property def preheater_duration(self): if self.preheater_duration_supported: res = ( self._vehicle.state["preheaterState"] .get("climatisationStateReport") .get("climatisationDuration") ) return parse_int(res) @property def preheater_duration_supported(self): return self.preheater_state_supported @property def preheater_remaining_supported(self): return self.preheater_state_supported @property def preheater_remaining(self): if self.preheater_remaining_supported: res = ( self._vehicle.state["preheaterState"] .get("climatisationStateReport") .get("remainingClimateTime") ) return parse_int(res) @property def parking_light(self): """Return true if parking light is on""" if self.parking_light_supported: try: check = self._vehicle.fields.get("LIGHT_STATUS") return check[0]["status"] != "off" or check[1]["status"] != "off" except KeyError: return False @property def parking_light_supported(self): """Return true if parking light is supported""" check = self._vehicle.fields.get("LIGHT_STATUS") if check: return True @property def braking_status(self): """Return true if braking status is on""" if self.braking_status_supported: check = self._vehicle.fields.get("BRAKING_STATUS") return check != "2" @property def braking_status_supported(self): """Return true if braking status is supported""" check = self._vehicle.fields.get("BRAKING_STATUS") if check: return True @property def mileage(self): if self.mileage_supported: check = self._vehicle.fields.get("UTC_TIME_AND_KILOMETER_STATUS") return parse_int(check) @property def mileage_supported(self): """Return true if mileage is supported""" check = self._vehicle.fields.get("UTC_TIME_AND_KILOMETER_STATUS") if check and parse_int(check): return True @property def range(self): if self.range_supported: check = self._vehicle.fields.get("TOTAL_RANGE") return parse_int(check) @property def range_supported(self): """Return true if range is supported""" check = self._vehicle.fields.get("TOTAL_RANGE") if check and parse_int(check): return True @property def tank_level(self): if self.tank_level_supported: check = self._vehicle.fields.get("TANK_LEVEL_IN_PERCENTAGE") return parse_int(check) @property def tank_level_supported(self): """Return true if tank_level is supported""" check = self._vehicle.fields.get("TANK_LEVEL_IN_PERCENTAGE") if check and parse_int(check): return True @property def position(self): """Return position.""" if self.position_supported: return self._vehicle.state.get("position") @property def position_supported(self): """Return true if vehicle has position.""" check = self._vehicle.state.get("position") if check: return True @property def any_window_open_supported(self): """Return true if window state is supported""" checkLeftFront = self._vehicle.fields.get("STATE_LEFT_FRONT_WINDOW") checkLeftRear = self._vehicle.fields.get("STATE_LEFT_REAR_WINDOW") checkRightFront = self._vehicle.fields.get("STATE_RIGHT_FRONT_WINDOW") checkRightRear = self._vehicle.fields.get("STATE_RIGHT_REAR_WINDOW") checkSunRoof = self._vehicle.fields.get("STATE_SUN_ROOF_MOTOR_COVER", None) checkRoofCover = self._vehicle.fields.get("STATE_ROOF_COVER_WINDOW", None) acceptable_window_states = ["3", "0", None] if ( checkLeftFront and checkLeftRear and checkRightFront and checkRightRear and (checkSunRoof in acceptable_window_states) and (checkRoofCover in acceptable_window_states) ): return True @property def any_window_open(self): if self.any_window_open_supported: checkLeftFront = self._vehicle.fields.get("STATE_LEFT_FRONT_WINDOW") checkLeftRear = self._vehicle.fields.get("STATE_LEFT_REAR_WINDOW") checkRightFront = self._vehicle.fields.get("STATE_RIGHT_FRONT_WINDOW") checkRightRear = self._vehicle.fields.get("STATE_RIGHT_REAR_WINDOW") checkSunRoof = self._vehicle.fields.get("STATE_SUN_ROOF_MOTOR_COVER", None) checkRoofCover = self._vehicle.fields.get("STATE_ROOF_COVER_WINDOW", None) acceptable_window_states = ["3", None] return not ( checkLeftFront == "3" and checkLeftRear == "3" and checkRightFront == "3" and checkRightRear == "3" and (checkSunRoof in acceptable_window_states) and (checkRoofCover in acceptable_window_states) ) @property def left_front_window_open_supported(self): return self._vehicle.fields.get("STATE_LEFT_FRONT_WINDOW") @property def left_front_window_open(self): if self.left_front_window_open_supported: return self._vehicle.fields.get("STATE_LEFT_FRONT_WINDOW") != "3" @property def right_front_window_open_supported(self): return self._vehicle.fields.get("STATE_RIGHT_FRONT_WINDOW") @property def right_front_window_open(self): if self.right_front_window_open_supported: return self._vehicle.fields.get("STATE_RIGHT_FRONT_WINDOW") != "3" @property def left_rear_window_open_supported(self): return self._vehicle.fields.get("STATE_LEFT_REAR_WINDOW") @property def left_rear_window_open(self): if self.left_rear_window_open_supported: return self._vehicle.fields.get("STATE_LEFT_REAR_WINDOW") != "3" @property def right_rear_window_open_supported(self): return self._vehicle.fields.get("STATE_RIGHT_REAR_WINDOW") @property def right_rear_window_open(self): if self.right_rear_window_open_supported: return self._vehicle.fields.get("STATE_RIGHT_REAR_WINDOW") != "3" @property def sun_roof_supported(self): return self._vehicle.fields.get("STATE_SUN_ROOF_MOTOR_COVER") @property def sun_roof(self): if self.sun_roof_supported: return self._vehicle.fields.get("STATE_SUN_ROOF_MOTOR_COVER") != "3" @property def roof_cover_supported(self): return self._vehicle.fields.get("STATE_ROOF_COVER_WINDOW") @property def roof_cover(self): if self.roof_cover_supported: return self._vehicle.fields.get("STATE_ROOF_COVER_WINDOW") != "3" @property def any_door_unlocked_supported(self): checkLeftFront = self._vehicle.fields.get("LOCK_STATE_LEFT_FRONT_DOOR") checkLeftRear = self._vehicle.fields.get("LOCK_STATE_LEFT_REAR_DOOR") checkRightFront = self._vehicle.fields.get("LOCK_STATE_RIGHT_FRONT_DOOR") checkRightRear = self._vehicle.fields.get("LOCK_STATE_RIGHT_REAR_DOOR") if checkLeftFront and checkLeftRear and checkRightFront and checkRightRear: return True @property def any_door_unlocked(self): if self.any_door_unlocked_supported: checkLeftFront = self._vehicle.fields.get("LOCK_STATE_LEFT_FRONT_DOOR") checkLeftRear = self._vehicle.fields.get("LOCK_STATE_LEFT_REAR_DOOR") checkRightFront = self._vehicle.fields.get("LOCK_STATE_RIGHT_FRONT_DOOR") checkRightRear = self._vehicle.fields.get("LOCK_STATE_RIGHT_REAR_DOOR") return not ( checkLeftFront == "2" and checkLeftRear == "2" and checkRightFront == "2" and checkRightRear == "2" ) @property def any_door_open_supported(self): checkLeftFront = self._vehicle.fields.get("OPEN_STATE_LEFT_FRONT_DOOR") checkLeftRear = self._vehicle.fields.get("OPEN_STATE_LEFT_REAR_DOOR") checkRightFront = self._vehicle.fields.get("OPEN_STATE_RIGHT_FRONT_DOOR") checkRightRear = self._vehicle.fields.get("OPEN_STATE_RIGHT_REAR_DOOR") if checkLeftFront and checkLeftRear and checkRightFront and checkRightRear: return True @property def any_door_open(self): if self.any_door_open_supported: checkLeftFront = self._vehicle.fields.get("OPEN_STATE_LEFT_FRONT_DOOR") checkLeftRear = self._vehicle.fields.get("OPEN_STATE_LEFT_REAR_DOOR") checkRightFront = self._vehicle.fields.get("OPEN_STATE_RIGHT_FRONT_DOOR") checkRightRear = self._vehicle.fields.get("OPEN_STATE_RIGHT_REAR_DOOR") return not ( checkLeftFront == "3" and checkLeftRear == "3" and checkRightFront == "3" and checkRightRear == "3" ) @property def left_front_door_open_supported(self): return self._vehicle.fields.get("OPEN_STATE_LEFT_FRONT_DOOR") @property def left_front_door_open(self): if self.left_front_door_open_supported: return self._vehicle.fields.get("OPEN_STATE_LEFT_FRONT_DOOR") != "3" @property def right_front_door_open_supported(self): return self._vehicle.fields.get("OPEN_STATE_RIGHT_FRONT_DOOR") @property def right_front_door_open(self): if self.right_front_door_open_supported: return self._vehicle.fields.get("OPEN_STATE_RIGHT_FRONT_DOOR") != "3" @property def left_rear_door_open_supported(self): return self._vehicle.fields.get("OPEN_STATE_LEFT_REAR_DOOR") @property def left_rear_door_open(self): if self.left_rear_door_open_supported: return self._vehicle.fields.get("OPEN_STATE_LEFT_REAR_DOOR") != "3" @property def right_rear_door_open_supported(self): return self._vehicle.fields.get("OPEN_STATE_RIGHT_REAR_DOOR") @property def right_rear_door_open(self): if self.right_rear_door_open_supported: return self._vehicle.fields.get("OPEN_STATE_RIGHT_REAR_DOOR") != "3" @property def doors_trunk_status_supported(self): return ( self.any_door_open_supported and self.any_door_unlocked_supported and self.trunk_open_supported and self.trunk_unlocked_supported ) @property def doors_trunk_status(self): if ( self.any_door_open_supported and self.any_door_unlocked_supported and self.trunk_open_supported and self.trunk_unlocked_supported ): if self.any_door_open or self.trunk_open: return "Open" elif self.any_door_unlocked or self.trunk_unlocked: return "Closed" else: return "Locked" @property def trunk_unlocked(self): if self.trunk_unlocked_supported: check = self._vehicle.fields.get("LOCK_STATE_TRUNK_LID") return check != "2" @property def trunk_unlocked_supported(self): check = self._vehicle.fields.get("LOCK_STATE_TRUNK_LID") if check: return True @property def trunk_open(self): if self.trunk_open_supported: check = self._vehicle.fields.get("OPEN_STATE_TRUNK_LID") return check != "3" @property def trunk_open_supported(self): check = self._vehicle.fields.get("OPEN_STATE_TRUNK_LID") if check: return True @property def hood_open(self): if self.hood_open_supported: check = self._vehicle.fields.get("OPEN_STATE_HOOD") return check != "3" @property def hood_open_supported(self): check = self._vehicle.fields.get("OPEN_STATE_HOOD") if check: return True @property def charging_state(self): """Return charging state""" if self.charging_state_supported: return self._vehicle.state.get("chargingState") @property def charging_state_supported(self): check = self._vehicle.state.get("chargingState") if check: return True @property def charging_mode(self): """Return charging mode""" if self.charging_mode_supported: return self._vehicle.state.get("chargeMode") @property def charging_mode_supported(self): check = self._vehicle.state.get("chargeMode") return check is not None and check != "unsupported" @property def energy_flow(self): """Return charging mode""" if self.energy_flow_supported: return self._vehicle.state.get("energyFlow") @property def energy_flow_supported(self): check = self._vehicle.state.get("energyFlow") if check is not None: return True @property def charging_type(self): """Return charging type""" if self.charging_type_supported: return self._vehicle.state.get("chargeType") @property def charging_type_supported(self): check = self._vehicle.state.get("chargeType") if check and check != "unsupported": return True @property def max_charge_current(self): """Return max charge current""" if self.max_charge_current_supported: try: return parse_float(self._vehicle.state.get("maxChargeCurrent")) except ValueError: return -1 @property def max_charge_current_supported(self): check = self._vehicle.state.get("maxChargeCurrent") if check is not None: return True @property def actual_charge_rate(self): """Return actual charge rate""" if self.actual_charge_rate_supported: try: return parse_float(self._vehicle.state.get("actualChargeRate")) except ValueError: return -1 @property def actual_charge_rate_supported(self): check = self._vehicle.state.get("actualChargeRate") if check is not None: return True @property def actual_charge_rate_unit(self): return "km/h" @property def charging_power(self): """Return charging power""" if self.charging_power_supported: try: return parse_float(self._vehicle.state.get("chargingPower")) except ValueError: return -1 @property def charging_power_supported(self): check = self._vehicle.state.get("chargingPower") if check is not None: return True @property def primary_engine_type(self): """Return primary engine type""" if self.primary_engine_type_supported: return self._vehicle.state.get("engineTypeFirstEngine") @property def primary_engine_type_supported(self): check = self._vehicle.state.get("engineTypeFirstEngine") if check and check != "unsupported": return True @property def secondary_engine_type(self): """Return secondary engine type""" if self.secondary_engine_type_supported: return self._vehicle.state.get("engineTypeSecondEngine") @property def secondary_engine_type_supported(self): check = self._vehicle.state.get("engineTypeSecondEngine") if check and check != "unsupported": return True @property def primary_engine_range(self): """Return primary engine range""" if self.primary_engine_range_supported: return self._vehicle.state.get("primaryEngineRange") @property def primary_engine_range_supported(self): check = self._vehicle.state.get("primaryEngineRange") if check and check != "unsupported": return True @property def primary_engine_range_percent(self): """Return primary engine range""" if self.primary_engine_range_percent_supported: return self._vehicle.state.get("primaryEngineRangePercent") @property def primary_engine_range_percent_supported(self): check = self._vehicle.state.get("primaryEngineRangePercent") if check and check != "unsupported": return True @property def secondary_engine_range(self): """Return secondary engine range""" if self.secondary_engine_range_supported: return self._vehicle.state.get("secondaryEngineRange") @property def secondary_engine_range_supported(self): check = self._vehicle.state.get("secondaryEngineRange") if check is not None and check != "unsupported": return True @property def car_type(self): """Return secondary engine range""" if self.car_type_supported: return self._vehicle.state.get("carType") @property def car_type_supported(self): check = self._vehicle.state.get("carType") if check and check != "unsupported": return True @property def secondary_engine_range_percent(self): """Return secondary engine range""" if self.secondary_engine_range_percent_supported: return self._vehicle.state.get("secondaryEngineRangePercent") @property def secondary_engine_range_percent_supported(self): check = self._vehicle.state.get("secondaryEngineRangePercent") if check and check != "unsupported": return True @property def hybrid_range(self): """Return hybrid range""" if self.hybrid_range_supported: return self._vehicle.state.get("hybridRange") @property def hybrid_range_supported(self): check = self._vehicle.state.get("hybridRange") if check and check != "unsupported": return True @property def state_of_charge(self): """Return state of charge""" if self.state_of_charge_supported: return parse_int(self._vehicle.state.get("stateOfCharge")) @property def state_of_charge_supported(self): return parse_int(self._vehicle.state.get("stateOfCharge")) is not None @property def remaining_charging_time(self): """Return remaining charging time""" if self.remaining_charging_time_supported: return self._vehicle.state.get("remainingChargingTime", 0) @property def remaining_charging_time_unit(self): return "min" @property def remaining_charging_time_supported(self): return self.car_type in ["hybrid", "electric"] @property def charging_complete_time(self): """Return the datetime when charging is or was expected to be complete.""" # Check if remaining charging time is not supported if not self.remaining_charging_time_supported: return None # If there's no last update or remaining time, we can't calculate if self.last_update_time is None or self.remaining_charging_time is None: return None # Calculate the complete time whenever there is a positive remaining time if self.remaining_charging_time > 0: calculated_time = self.last_update_time + timedelta( minutes=self.remaining_charging_time ) self.charging_complete_time_frozen = ( calculated_time # Always update the frozen time ) return calculated_time # If the remaining time is zero or negative, and no frozen time is set, # we have no knowledge of the last completion time, so return None if self.charging_complete_time_frozen is None: return None # Otherwise, return the frozen complete time return self.charging_complete_time_frozen @property def target_state_of_charge(self): """Return state of charge""" if self.target_state_of_charge_supported: return parse_int(self._vehicle.state.get("targetstateOfCharge")) @property def target_state_of_charge_supported(self): return parse_int(self._vehicle.state.get("targetstateOfCharge")) is not None @property def plug_state(self): """Return plug state""" if self.plug_state_supported: check = self._vehicle.state.get("plugState") return check != "disconnected" @property def plug_state_supported(self): check = self._vehicle.state.get("plugState") if check: return True @property def plug_lock_state(self): """Return plug lock state""" if self.plug_lock_state_supported: check = self._vehicle.state.get("plugLockState") return check != "locked" @property def plug_lock_state_supported(self): check = self._vehicle.state.get("plugLockState") if check: return True @property def external_power(self): """Return external Power""" if self.external_power_supported: external_power_status = self._vehicle.state.get("externalPower") if external_power_status == "unavailable": return "Not Ready" elif external_power_status == "ready": return "Ready" else: return external_power_status @property def external_power_supported(self): return self._vehicle.state.get("externalPower") is not None @property def plug_led_color(self): """Return plug LED Color""" if self.plug_led_color_supported: return self._vehicle.state.get("plugledColor") @property def plug_led_color_supported(self): check = self._vehicle.state.get("plugledColor") if check: return True @property def climatisation_state(self): if self.climatisation_state_supported: return self._vehicle.state.get("climatisationState") @property def climatisation_state_supported(self): check = self._vehicle.state.get("climatisationState") if check: return True @property def outdoor_temperature(self): if self.outdoor_temperature_supported: return self._vehicle.state.get("outdoorTemperature") @property def outdoor_temperature_supported(self): check = self._vehicle.state.get("outdoorTemperature") if check: return True @property def glass_surface_heating(self): if self.glass_surface_heating_supported: return self._vehicle.state.get("isMirrorHeatingActive") @property def glass_surface_heating_supported(self): return self._vehicle.state.get("isMirrorHeatingActive") is not None @property def park_time(self): if self.park_time_supported: return self._vehicle.state.get("vehicleParkingClock") @property def park_time_supported(self): return self._vehicle.state.get("vehicleParkingClock") is not None @property def remaining_climatisation_time(self): if self.remaining_climatisation_time_supported: remaining_time = self._vehicle.state.get("remainingClimatisationTime") if remaining_time is not None and remaining_time < 0: return 0 elif remaining_time is not None: return remaining_time return None @property def remaining_climatisation_time_supported(self): return self._vehicle.state.get("remainingClimatisationTime") is not None @property def preheater_state(self): check = self._vehicle.state.get("preheaterState") if check: return True @property def preheater_state_supported(self): check = self._vehicle.state.get("preheaterState") if check: return True def lock_supported(self): return ( self.doors_trunk_status_supported and self._audi_service._spin is not None ) @property def shortterm_current(self): """Return shortterm.""" if self.shortterm_current_supported: return self._vehicle.state.get("shortterm_current") @property def shortterm_current_supported(self): """Return true if vehicle has shortterm_current.""" check = self._vehicle.state.get("shortterm_current") if check: return True @property def shortterm_reset(self): """Return shortterm.""" if self.shortterm_reset_supported: return self._vehicle.state.get("shortterm_reset") @property def shortterm_reset_supported(self): """Return true if vehicle has shortterm_reset.""" check = self._vehicle.state.get("shortterm_reset") if check: return True @property def longterm_current(self): """Return longterm.""" if self.longterm_current_supported: return self._vehicle.state.get("longterm_current") @property def longterm_current_supported(self): """Return true if vehicle has longterm_current.""" check = self._vehicle.state.get("longterm_current") if check: return True @property def longterm_reset(self): """Return longterm.""" if self.longterm_reset_supported: return self._vehicle.state.get("longterm_reset") @property def longterm_reset_supported(self): """Return true if vehicle has longterm_reset.""" check = self._vehicle.state.get("longterm_reset") if check: return True @property def is_moving(self): """Return true if the vehicle is moving.""" if self.is_moving_supported: return self._vehicle.state.get("is_moving") @property def is_moving_supported(self): """Return true if vehicle can move.""" return True ================================================ FILE: custom_components/audiconnect/audi_entity.py ================================================ from homeassistant.helpers.entity import Entity from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, ) from homeassistant.helpers.entity import DeviceInfo from .const import DOMAIN, SIGNAL_STATE_UPDATED class AudiEntity(Entity): """Base class for all Audi entities.""" def __init__(self, data, instrument): """Initialize the entity.""" self._data = data self._instrument = instrument self._vin = self._instrument.vehicle_name self._component = self._instrument.component self._attribute = self._instrument.attr async def async_added_to_hass(self): """Register update dispatcher.""" async_dispatcher_connect( self.hass, SIGNAL_STATE_UPDATED, self.async_schedule_update_ha_state ) @property def icon(self): """Return the icon.""" return self._instrument.icon @property def _entity_name(self): return self._instrument.name @property def _vehicle_name(self): return self._instrument.vehicle_name @property def name(self): """Return full name of the entity.""" return "{} {}".format(self._vehicle_name, self._entity_name) @property def should_poll(self): """Return the polling state.""" return False @property def assumed_state(self): """Return true if unable to access real state of entity.""" return True @property def extra_state_attributes(self): """Return device specific state attributes.""" return dict( self._instrument.attributes, model="{}/{}".format( self._instrument.vehicle_model, self._instrument.vehicle_name ), model_year=self._instrument.vehicle_model_year, model_family=self._instrument.vehicle_model_family, title=self._instrument.vehicle_name, csid=self._instrument.vehicle_csid, vin=self._instrument.vehicle_vin, ) @property def unique_id(self): """Return a unique ID.""" return self._instrument.full_name @property def device_info(self): """Return device information.""" if self._instrument.vehicle_model: model_info = self._instrument.vehicle_model.replace("Audi ", "") elif self._instrument.vehicle_name: model_info = self._instrument.vehicle_name else: model_info = "Unknown" return DeviceInfo( identifiers={(DOMAIN, self._instrument.vehicle_name)}, manufacturer="Audi", name=self._instrument.vehicle_name, model="{} ({})".format(model_info, self._instrument.vehicle_model_year), ) ================================================ FILE: custom_components/audiconnect/audi_models.py ================================================ import logging from .util import get_attr _LOGGER = logging.getLogger(__name__) class VehicleData: def __init__(self, config_entry): self.sensors = set() self.binary_sensors = set() self.switches = set() self.device_trackers = set() self.locks = set() self.config_entry = config_entry self.vehicle = None class CurrentVehicleDataResponse: def __init__(self, data): data = data["CurrentVehicleDataResponse"] self.request_id = data["requestId"] self.vin = data["vin"] class VehicleDataResponse: OLDAPI_MAPPING = { "frontRightLock": "LOCK_STATE_RIGHT_FRONT_DOOR", "frontRightOpen": "OPEN_STATE_RIGHT_FRONT_DOOR", "frontLeftLock": "LOCK_STATE_LEFT_FRONT_DOOR", "frontLeftOpen": "OPEN_STATE_LEFT_FRONT_DOOR", "rearRightLock": "LOCK_STATE_RIGHT_REAR_DOOR", "rearRightOpen": "OPEN_STATE_RIGHT_REAR_DOOR", "rearLeftLock": "LOCK_STATE_LEFT_REAR_DOOR", "rearLeftOpen": "OPEN_STATE_LEFT_REAR_DOOR", "trunkLock": "LOCK_STATE_TRUNK_LID", "trunkOpen": "OPEN_STATE_TRUNK_LID", "bonnetLock": "LOCK_STATE_HOOD", "bonnetOpen": "OPEN_STATE_HOOD", "sunRoofWindow": "STATE_SUN_ROOF_MOTOR_COVER", "frontLeftWindow": "STATE_LEFT_FRONT_WINDOW", "frontRightWindow": "STATE_RIGHT_FRONT_WINDOW", "rearLeftWindow": "STATE_LEFT_REAR_WINDOW", "rearRightWindow": "STATE_RIGHT_REAR_WINDOW", "roofCoverWindow": "STATE_ROOF_COVER_WINDOW", } def __init__(self, data): self.data_fields = [] self.states = [] self._tryAppendFieldWithTs( data, "TOTAL_RANGE", ["fuelStatus", "rangeStatus", "value", "totalRange_km"] ) self._tryAppendFieldWithTs( data, "TANK_LEVEL_IN_PERCENTAGE", ["measurements", "fuelLevelStatus", "value", "currentFuelLevel_pct"], ) self._tryAppendFieldWithTs( data, "UTC_TIME_AND_KILOMETER_STATUS", ["measurements", "odometerStatus", "value", "odometer"], ) self._tryAppendFieldWithTs( data, "MAINTENANCE_INTERVAL_TIME_TO_INSPECTION", [ "vehicleHealthInspection", "maintenanceStatus", "value", "inspectionDue_days", ], ) self._tryAppendFieldWithTs( data, "MAINTENANCE_INTERVAL_DISTANCE_TO_INSPECTION", [ "vehicleHealthInspection", "maintenanceStatus", "value", "inspectionDue_km", ], ) self._tryAppendFieldWithTs( data, "MAINTENANCE_INTERVAL_TIME_TO_OIL_CHANGE", [ "vehicleHealthInspection", "maintenanceStatus", "value", "oilServiceDue_days", ], ) self._tryAppendFieldWithTs( data, "MAINTENANCE_INTERVAL_DISTANCE_TO_OIL_CHANGE", [ "vehicleHealthInspection", "maintenanceStatus", "value", "oilServiceDue_km", ], ) self._tryAppendFieldWithTs( data, "OIL_LEVEL_DIPSTICKS_PERCENTAGE", ["oilLevel", "oilLevelStatus", "value", "value"], ) self._tryAppendFieldWithTs( data, "ADBLUE_RANGE", ["measurements", "rangeStatus", "value", "adBlueRange"], ) self._tryAppendFieldWithTs( data, "LIGHT_STATUS", ["vehicleLights", "lightsStatus", "value", "lights"] ) self.appendWindowState(data) self.appendDoorState(data) self._tryAppendStateWithTs( data, "carType", -1, ["fuelStatus", "rangeStatus", "value", "carType"] ) self._tryAppendStateWithTs( data, "engineTypeFirstEngine", -2, ["fuelStatus", "rangeStatus", "value", "primaryEngine", "type"], ) self._tryAppendStateWithTs( data, "primaryEngineRange", -2, [ "fuelStatus", "rangeStatus", "value", "primaryEngine", "remainingRange_km", ], ) self._tryAppendStateWithTs( data, "primaryEngineRangePercent", -2, ["fuelStatus", "rangeStatus", "value", "primaryEngine", "currentSOC_pct"], ) self._tryAppendStateWithTs( data, "engineTypeSecondEngine", -2, ["fuelStatus", "rangeStatus", "value", "secondaryEngine", "type"], ) self._tryAppendStateWithTs( data, "secondaryEngineRange", -2, [ "fuelStatus", "rangeStatus", "value", "secondaryEngine", "remainingRange_km", ], ) self._tryAppendStateWithTs( data, "secondaryEngineRangePercent", -2, ["fuelStatus", "rangeStatus", "value", "secondaryEngine", "currentSOC_pct"], ) self._tryAppendStateWithTs( data, "hybridRange", -1, ["fuelStatus", "rangeStatus", "value", "totalRange_km"], ) self._tryAppendStateWithTs( data, "stateOfCharge", -1, ["charging", "batteryStatus", "value", "currentSOC_pct"], ) self._tryAppendStateWithTs( data, "chargingState", -1, ["charging", "chargingStatus", "value", "chargingState"], ) self._tryAppendStateWithTs( data, "chargeMode", -1, ["charging", "chargingStatus", "value", "chargeMode"], ) self._tryAppendStateWithTs( data, "chargingPower", -1, ["charging", "chargingStatus", "value", "chargePower_kW"], ) self._tryAppendStateWithTs( data, "actualChargeRate", -1, ["charging", "chargingStatus", "value", "chargeRate_kmph"], ) self._tryAppendStateWithTs( data, "chargeType", -1, ["charging", "chargingStatus", "value", "chargeType"], ) self._tryAppendStateWithTs( data, "targetstateOfCharge", -1, ["charging", "chargingSettings", "value", "targetSOC_pct"], ) self._tryAppendStateWithTs( data, "plugState", -1, ["charging", "plugStatus", "value", "plugConnectionState"], ) self._tryAppendStateWithTs( data, "remainingChargingTime", -1, [ "charging", "chargingStatus", "value", "remainingChargingTimeToComplete_min", ], ) self._tryAppendStateWithTs( data, "plugLockState", -1, ["charging", "plugStatus", "value", "plugLockState"], ) self._tryAppendStateWithTs( data, "externalPower", -1, ["charging", "plugStatus", "value", "externalPower"], ) self._tryAppendStateWithTs( data, "plugledColor", -1, ["charging", "plugStatus", "value", "ledColor"], ) self._tryAppendStateWithTs( data, "climatisationState", -1, ["climatisation", "auxiliaryHeatingStatus", "value", "climatisationState"], ) # 2024 Q4 updated data structure for climate data self._tryAppendStateWithTs( data, "climatisationState", -1, ["climatisation", "climatisationStatus", "value", "climatisationState"], ) self._tryAppendStateWithTs( data, "remainingClimatisationTime", -1, [ "climatisation", "climatisationStatus", "value", "remainingClimatisationTime_min", ], ) def _tryAppendStateWithTs(self, json, name, tsoff, loc): _LOGGER.debug( "TRY APPEND STATE: Searching for '%s' at location=%s, tsoff=%s", name, loc, tsoff, ) ts = None val = self._getFromJson(json, loc) # _LOGGER.debug("Initial value retrieved for '%s': %s", name, val) if val is not None: loc[tsoff:] = ["carCapturedTimestamp"] # _LOGGER.debug("Updated loc for timestamp retrieval: %s", loc) ts = self._getFromJson(json, loc) # _LOGGER.debug("Timestamp retrieved for '%s': %s", name, ts) if val is not None and ts: self.states.append({"name": name, "value": val, "measure_time": ts}) _LOGGER.debug( "TRY APPEND STATE: Found '%s' with value=%s, tsoff=%s, loc=%s, ts=%s", name, val, tsoff, loc, ts, ) else: if val is None: _LOGGER.debug( "TRY APPEND STATE: Value for '%s' is None; not appending state.", name, ) elif not ts: _LOGGER.debug( "TRY APPEND STATE: Timestamp for '%s' is None or missing; not appending state.", name, ) def _tryAppendFieldWithTs(self, json, textId, loc): _LOGGER.debug( "TRY APPEND FIELD: Searching for '%s' at location=%s", textId, loc, ) ts = None val = self._getFromJson(json, loc) # _LOGGER.debug("Initial value retrieved for '%s': %s", textId, val) if val is not None: loc[-1:] = ["carCapturedTimestamp"] # _LOGGER.debug("Updated loc for timestamp retrieval: %s", loc) ts = self._getFromJson(json, loc) # _LOGGER.debug("Timestamp retrieved for '%s': %s", textId, ts) if val is not None and ts: self.data_fields.append( Field( { "textId": textId, "value": val, "tsCarCaptured": ts, } ) ) _LOGGER.debug( "TRY APPEND FIELD: Found '%s' with value=%s, loc=%s, ts=%s", textId, val, loc, ts, ) else: if val is None: _LOGGER.debug( "TRY APPEND FIELD: Value for '%s' is None or missing; not appending field.", textId, ) elif not ts: _LOGGER.debug( "TRY APPEND FIELD: Timestamp for '%s' is None or missing; not appending field.", textId, ) def _getFromJson(self, json, loc): child = json for i in loc: if i not in child: return None child = child[i] return child def appendDoorState(self, data): _LOGGER.debug("APPEND DOOR: Starting to append doors...") doors = get_attr(data, "access.accessStatus.value.doors", []) tsCarCapturedAccess = get_attr( data, "access.accessStatus.value.carCapturedTimestamp" ) _LOGGER.debug( "APPEND DOOR: Timestamp captured from car: %s", tsCarCapturedAccess ) for door in doors: status = door["status"] name = door["name"] _LOGGER.debug( "APPEND DOOR: Processing door: %s with status: %s", name, status ) if name + "Lock" not in self.OLDAPI_MAPPING: _LOGGER.debug( "APPEND DOOR: Skipping door not mapped in OLDAPI_MAPPING: %s", name ) continue lock = "0" open = "0" unsupported = False for state in status: if state == "unsupported": unsupported = True _LOGGER.debug("APPEND DOOR: Unsupported state for door: %s", name) if state == "locked": lock = "2" if state == "closed": open = "3" if not unsupported: doorFieldLock = { "textId": self.OLDAPI_MAPPING[name + "Lock"], "value": lock, "tsCarCaptured": tsCarCapturedAccess, } _LOGGER.debug( "APPEND DOOR: Appended door lock field: %s", doorFieldLock ) self.data_fields.append(Field(doorFieldLock)) doorFieldOpen = { "textId": self.OLDAPI_MAPPING[name + "Open"], "value": open, "tsCarCaptured": tsCarCapturedAccess, } _LOGGER.debug( "APPEND DOOR: Appended door open field: %s", doorFieldOpen ) self.data_fields.append(Field(doorFieldOpen)) _LOGGER.debug("APPEND DOOR: Finished appending doors") def appendWindowState(self, data): _LOGGER.debug("APPEND WINDOW: Starting to append windows...") windows = get_attr(data, "access.accessStatus.value.windows", []) tsCarCapturedAccess = get_attr( data, "access.accessStatus.value.carCapturedTimestamp" ) _LOGGER.debug( "APPEND WINDOW: Timestamp captured from car: %s", tsCarCapturedAccess ) for window in windows: name = window["name"] status = window["status"] _LOGGER.debug( "APPEND WINDOW: Processing window: %s with status: %s", name, status ) if ( status[0] == "unsupported" ) or name + "Window" not in self.OLDAPI_MAPPING: _LOGGER.debug( "APPEND WINDOW: Skipping unsupported window or not mapped in OLDAPI_MAPPING: %s", name, ) continue windowField = { "textId": self.OLDAPI_MAPPING[name + "Window"], "value": "3" if status[0] == "closed" else "0", "tsCarCaptured": tsCarCapturedAccess, } _LOGGER.debug("APPEND WINDOW: Appended window field: %s", windowField) self.data_fields.append(Field(windowField)) _LOGGER.debug("APPEND WINDOW: Finished appending windows") class TripDataResponse: def __init__(self, data): self.data_fields = [] self.tripID = data["tripID"] self.averageElectricEngineConsumption = None if "averageElectricEngineConsumption" in data: self.averageElectricEngineConsumption = ( float(data["averageElectricEngineConsumption"]) / 10 ) self.averageFuelConsumption = None if "averageFuelConsumption" in data: self.averageFuelConsumption = float(data["averageFuelConsumption"]) / 10 self.averageSpeed = None if "averageSpeed" in data: self.averageSpeed = int(data["averageSpeed"]) self.mileage = None if "mileage" in data: self.mileage = int(data["mileage"]) self.startMileage = None if "startMileage" in data: self.startMileage = int(data["startMileage"]) self.traveltime = None if "traveltime" in data: self.traveltime = int(data["traveltime"]) self.timestamp = None if "timestamp" in data: self.timestamp = data["timestamp"] self.overallMileage = None if "overallMileage" in data: self.overallMileage = int(data["overallMileage"]) self.zeroEmissionDistance = None if "zeroEmissionDistance" in data: self.zeroEmissionDistance = int(data["zeroEmissionDistance"]) class Field: IDS = { "0x0": "UNKNOWN", "0x0101010002": "UTC_TIME_AND_KILOMETER_STATUS", "0x0203010001": "MAINTENANCE_INTERVAL_DISTANCE_TO_OIL_CHANGE", "0x0203010002": "MAINTENANCE_INTERVAL_TIME_TO_OIL_CHANGE", "0x0203010003": "MAINTENANCE_INTERVAL_DISTANCE_TO_INSPECTION", "0x0203010004": "MAINTENANCE_INTERVAL_TIME_TO_INSPECTION", "0x0203010006": "MAINTENANCE_INTERVAL_ALARM_INSPECTION", "0x0203010007": "MAINTENANCE_INTERVAL_MONTHLY_MILEAGE", "0x0203010005": "WARNING_OIL_CHANGE", "0x0204040001": "OIL_LEVEL_AMOUNT_IN_LITERS", "0x0204040002": "OIL_LEVEL_MINIMUM_WARNING", "0x0204040003": "OIL_LEVEL_DIPSTICKS_PERCENTAGE", "0x02040C0001": "ADBLUE_RANGE", "0x0301010001": "LIGHT_STATUS", "0x0301030001": "BRAKING_STATUS", "0x0301030005": "TOTAL_RANGE", "0x030103000A": "TANK_LEVEL_IN_PERCENTAGE", "0x0301040001": "LOCK_STATE_LEFT_FRONT_DOOR", "0x0301040002": "OPEN_STATE_LEFT_FRONT_DOOR", "0x0301040003": "SAFETY_STATE_LEFT_FRONT_DOOR", "0x0301040004": "LOCK_STATE_LEFT_REAR_DOOR", "0x0301040005": "OPEN_STATE_LEFT_REAR_DOOR", "0x0301040006": "SAFETY_STATE_LEFT_REAR_DOOR", "0x0301040007": "LOCK_STATE_RIGHT_FRONT_DOOR", "0x0301040008": "OPEN_STATE_RIGHT_FRONT_DOOR", "0x0301040009": "SAFETY_STATE_RIGHT_FRONT_DOOR", "0x030104000A": "LOCK_STATE_RIGHT_REAR_DOOR", "0x030104000B": "OPEN_STATE_RIGHT_REAR_DOOR", "0x030104000C": "SAFETY_STATE_RIGHT_REAR_DOOR", "0x030104000D": "LOCK_STATE_TRUNK_LID", "0x030104000E": "OPEN_STATE_TRUNK_LID", "0x030104000F": "SAFETY_STATE_TRUNK_LID", "0x0301040010": "LOCK_STATE_HOOD", "0x0301040011": "OPEN_STATE_HOOD", "0x0301040012": "SAFETY_STATE_HOOD", "0x0301050001": "STATE_LEFT_FRONT_WINDOW", "0x0301050003": "STATE_LEFT_REAR_WINDOW", "0x0301050005": "STATE_RIGHT_FRONT_WINDOW", "0x0301050007": "STATE_RIGHT_REAR_WINDOW", "0x0301050009": "STATE_DECK", "0x030105000B": "STATE_SUN_ROOF_MOTOR_COVER", "0x0301030006": "PRIMARY_RANGE", "0x0301030007": "PRIMARY_DRIVE", "0x0301030008": "SECONDARY_RANGE", "0x0301030009": "SECONDARY_DRIVE", "0x0301030002": "STATE_OF_CHARGE", "0x0301020001": "TEMPERATURE_OUTSIDE", "0x0202": "ACTIVE_INSTRUMENT_CLUSTER_WARNING", } def __init__(self, data): self.name = None self.id = data.get("id") self.unit = data.get("unit") self.value = data.get("value") self.measure_time = data.get("tsTssReceivedUtc") if self.measure_time is None: self.measure_time = data.get("tsCarCaptured") self.send_time = data.get("tsCarSentUtc") self.measure_mileage = data.get("milCarCaptured") self.send_mileage = data.get("milCarSent") for field_id, name in self.IDS.items(): if field_id == self.id: self.name = name break if self.name is None: # No direct mapping found - maybe we've at least got a text id self.name = data.get("textId") def __str__(self): str_rep = str(self.name) + " " + str(self.value) if self.unit is not None: str_rep += self.unit return str_rep class Vehicle: def __init__(self): self.vin = "" self.csid = "" self.model = "" self.model_year = "" self.model_family = "" self.title = "" def parse(self, data): self.vin = data.get("vin") self.csid = data.get("csid") if ( data.get("vehicle") is not None and data.get("vehicle").get("media") is not None ): self.model = data.get("vehicle").get("media").get("longName") if ( data.get("vehicle") is not None and data.get("vehicle").get("core") is not None ): self.model_year = data.get("vehicle").get("core").get("modelYear") if data.get("nickname") is not None and len(data.get("nickname")) > 0: self.title = data.get("nickname") elif ( data.get("vehicle") is not None and data.get("vehicle").get("media") is not None ): self.title = data.get("vehicle").get("media").get("shortName") def __str__(self): return str(self.__dict__) class VehiclesResponse: def __init__(self): self.vehicles = [] self.blacklisted_vins = 0 def parse(self, data): user_vehicles = data.get("userVehicles") if user_vehicles is None: _LOGGER.warning("No vehicle data received from API. Check authentication.") return for item in user_vehicles: vehicle = Vehicle() vehicle.parse(item) self.vehicles.append(vehicle) ================================================ FILE: custom_components/audiconnect/audi_services.py ================================================ import json import uuid import base64 import os import re import logging from datetime import timedelta, datetime from typing import Optional from .audi_models import ( TripDataResponse, CurrentVehicleDataResponse, VehicleDataResponse, VehiclesResponse, ) from .audi_api import AudiAPI from .const import DEFAULT_API_LEVEL from .util import to_byte_array, get_attr from hashlib import sha256, sha512 import hmac import asyncio from urllib.parse import urlparse, parse_qs, urlencode import requests from bs4 import BeautifulSoup from requests import RequestException from typing import Dict MAX_RESPONSE_ATTEMPTS = 10 REQUEST_STATUS_SLEEP = 10 SUCCEEDED = "succeeded" FAILED = "failed" REQUEST_SUCCESSFUL = "request_successful" REQUEST_FAILED = "request_failed" _LOGGER = logging.getLogger(__name__) class BrowserLoginResponse: def __init__(self, response: requests.Response, url: str): self.response = response # type: requests.Response self.url = url # type : str def get_location(self) -> str: """ Returns the location the previous request redirected to """ location = self.response.headers["Location"] if location.startswith("/"): # Relative URL return BrowserLoginResponse.to_absolute(self.url, location) return location @classmethod def to_absolute(cls, absolute_url, relative_url) -> str: """ Converts a relative url to an absolute url :param absolute_url: Absolute url used as baseline :param relative_url: Relative url (must start with /) :return: New absolute url """ url_parts = urlparse(absolute_url) return url_parts.scheme + "://" + url_parts.netloc + relative_url class AudiService: def __init__(self, api: AudiAPI, country: str, spin: str, api_level: int): self._api = api self._country = country self._language = None self._type = "Audi" self._spin = spin self._homeRegion = {} self._homeRegionSetter = {} self.mbbOAuthBaseURL = None self.mbboauthToken = None self.xclientId = None self._tokenEndpoint = "" self._bearer_token_json = None self._client_id = "" self._authorizationServerBaseURLLive = "" self._api_level = api_level if self._api_level is None: self._api_level = DEFAULT_API_LEVEL if self._country is None: self._country = "DE" def get_hidden_html_input_form_data(self, response, form_data: Dict[str, str]): # Now parse the html body and extract the target url, csrf token and other required parameters html = BeautifulSoup(response, "html.parser") form_inputs = html.find_all("input", attrs={"type": "hidden"}) for form_input in form_inputs: name = form_input.get("name") form_data[name] = form_input.get("value") return form_data def get_post_url(self, response, url): # Now parse the html body and extract the target url, csrf token and other required parameters html = BeautifulSoup(response, "html.parser") form_tag = html.find("form") # Extract the target url action = form_tag.get("action") if action.startswith("http"): # Absolute url username_post_url = action elif action.startswith("/"): # Relative to domain username_post_url = BrowserLoginResponse.to_absolute(url, action) else: raise RequestException("Unknown form action: " + action) return username_post_url async def login(self, user: str, password: str, persist_token: bool = True): _LOGGER.debug("LOGIN: Starting login to Audi service...") await self.login_request(user, password) async def refresh_vehicle_data(self, vin: str): res = await self.request_current_vehicle_data(vin.upper()) request_id = res.request_id checkUrl = "{homeRegion}/fs-car/bs/vsr/v1/{type}/{country}/vehicles/{vin}/requests/{requestId}/jobstatus".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), requestId=request_id, ) await self.check_request_succeeded( checkUrl, "refresh vehicle data", REQUEST_SUCCESSFUL, REQUEST_FAILED, "requestStatusResponse.status", ) async def request_current_vehicle_data(self, vin: str): self._api.use_token(self.vwToken) data = await self._api.post( "{homeRegion}/fs-car/bs/vsr/v1/{type}/{country}/vehicles/{vin}/requests".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ) ) return CurrentVehicleDataResponse(data) async def get_preheater(self, vin: str): self._api.use_token(self.vwToken) return await self._api.get( "{homeRegion}/fs-car/bs/rs/v1/{type}/{country}/vehicles/{vin}/status".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ) ) async def get_stored_vehicle_data(self, vin: str): redacted_vin = "*" * (len(vin) - 4) + vin[-4:] JOBS2QUERY = { "access", "activeVentilation", "auxiliaryHeating", "batteryChargingCare", "batterySupport", "charging", "chargingProfiles", "chargingTimers", "climatisation", "climatisationTimers", "departureProfiles", "departureTimers", "fuelStatus", "honkAndFlash", "hybridCarAuxiliaryHeating", "lvBattery", "measurements", "oilLevel", "readiness", # "userCapabilities", "vehicleHealthInspection", "vehicleHealthWarnings", "vehicleLights", } self._api.use_token(self._bearer_token_json) data = await self._api.get( self.__get_cariad_url_for_vin( vin, "selectivestatus?jobs={jobs}", jobs=",".join(JOBS2QUERY) ) ) _LOGGER.debug("Vehicle data returned for VIN: %s: %s", redacted_vin, data) return VehicleDataResponse(data) async def get_charger(self, vin: str): self._api.use_token(self.vwToken) return await self._api.get( "{homeRegion}/fs-car/bs/batterycharge/v1/{type}/{country}/vehicles/{vin}/charger".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ) ) async def get_climater(self, vin: str): self._api.use_token(self.vwToken) return await self._api.get( "{homeRegion}/fs-car/bs/climatisation/v1/{type}/{country}/vehicles/{vin}/climater".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ) ) async def get_stored_position(self, vin: str): self._api.use_token(self._bearer_token_json) return await self._api.get( self.__get_cariad_url_for_vin(vin, "parkingposition") ) async def get_operations_list(self, vin: str): self._api.use_token(self.vwToken) return await self._api.get( "https://mal-1a.prd.ece.vwg-connect.com/api/rolesrights/operationlist/v3/vehicles/" + vin.upper() ) async def get_timer(self, vin: str): self._api.use_token(self.vwToken) return await self._api.get( "{homeRegion}/fs-car/bs/departuretimer/v1/{type}/{country}/vehicles/{vin}/timer".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ) ) async def get_vehicles(self): self._api.use_token(self.vwToken) return await self._api.get( "https://msg.volkswagen.de/fs-car/usermanagement/users/v1/{type}/{country}/vehicles".format( type=self._type, country=self._country ) ) async def get_vehicle_information(self): headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "X-App-Name": "myAudi", "X-App-Version": AudiAPI.HDR_XAPP_VERSION, "Accept-Language": "{l}-{c}".format( l=self._language, c=self._country.upper() ), "X-User-Country": self._country.upper(), "User-Agent": AudiAPI.HDR_USER_AGENT, "Authorization": "Bearer " + self.audiToken["access_token"], "Content-Type": "application/json; charset=utf-8", } req_data = { "query": "query vehicleList {\n userVehicles {\n vin\n mappingVin\n vehicle { core { modelYear\n }\n media { shortName\n longName }\n }\n csid\n commissionNumber\n type\n devicePlatform\n mbbConnect\n userRole {\n role\n }\n vehicle {\n classification {\n driveTrain\n }\n }\n nickname\n }\n}" } req_rsp, rep_rsptxt = await self._api.request( "POST", "https://app-api.my.aoa.audi.com/vgql/v1/graphql" if self._country.upper() == "US" else "https://app-api.live-my.audi.com/vgql/v1/graphql", # Starting in 2023, US users need to point at the aoa (Audi of America) URL. json.dumps(req_data), headers=headers, allow_redirects=False, rsp_wtxt=True, ) vins = json.loads(rep_rsptxt) if "errors" in vins: raise Exception(f"API returned errors: {vins['errors']}") if "data" not in vins or vins["data"] is None: raise Exception("No data in API response") if vins["data"].get("userVehicles") is None: raise Exception( "No vehicle data in API response - possible authentication issue" ) response = VehiclesResponse() response.parse(vins["data"]) return response async def get_vehicle_data(self, vin: str): self._api.use_token(self.vwToken) return await self._api.get( "{homeRegion}/fs-car/vehicleMgmt/vehicledata/v2/{type}/{country}/vehicles/{vin}/".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ) ) async def get_tripdata(self, vin: str, kind: str): self._api.use_token(self.vwToken) # read tripdata headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "X-App-Name": "myAudi", "X-App-Version": AudiAPI.HDR_XAPP_VERSION, "X-Client-ID": self.xclientId, "User-Agent": AudiAPI.HDR_USER_AGENT, "Authorization": "Bearer " + self.vwToken["access_token"], } td_reqdata = { "type": "list", "from": "1970-01-01T00:00:00Z", # "from":(datetime.utcnow() - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ"), "to": (datetime.utcnow() + timedelta(minutes=90)).strftime( "%Y-%m-%dT%H:%M:%SZ" ), } data = await self._api.request( "GET", "{homeRegion}/api/bs/tripstatistics/v1/vehicles/{vin}/tripdata/{kind}".format( homeRegion=await self._get_home_region_setter(vin.upper()), vin=vin.upper(), kind=kind, ), None, params=td_reqdata, headers=headers, ) td_sorted = sorted( data["tripDataList"]["tripData"], key=lambda k: k["overallMileage"], reverse=True, ) # _LOGGER.debug("get_tripdata: td_sorted: %s", td_sorted) td_current = td_sorted[0] # FIX, TR/2023-03-25: Assign just in case td_sorted contains only one item td_reset_trip = td_sorted[0] for trip in td_sorted: if (td_current["startMileage"] - trip["startMileage"]) > 2: td_reset_trip = trip break else: td_current["tripID"] = trip["tripID"] td_current["startMileage"] = trip["startMileage"] _LOGGER.debug("TRIP DATA: td_current: %s", td_current) _LOGGER.debug("TRIP DATA: td_reset_trip: %s", td_reset_trip) return TripDataResponse(td_current), TripDataResponse(td_reset_trip) async def _fill_home_region(self, vin: str): # the home-region endpoint returns # https://ha-5a.prd.eu.vwg.vwautocloud.net which is no valid endpoint # (at least not in DE region). set it statically. if self._country.upper() != "US" and self._api_level == 1: self._homeRegion[vin] = "https://mal-3a.prd.eu.dp.vwg-connect.com" self._homeRegionSetter[vin] = "https://mal-3a.prd.eu.dp.vwg-connect.com" return self._homeRegion[vin] = "https://msg.volkswagen.de" self._homeRegionSetter[vin] = "https://mal-1a.prd.ece.vwg-connect.com" try: self._api.use_token(self.vwToken) res = await self._api.get( "https://mal-1a.prd.ece.vwg-connect.com/api/cs/vds/v1/vehicles/{vin}/homeRegion".format( vin=vin ) ) if ( res is not None and res.get("homeRegion") is not None and res["homeRegion"].get("baseUri") is not None and res["homeRegion"]["baseUri"].get("content") is not None ): uri = res["homeRegion"]["baseUri"]["content"] if uri != "https://mal-1a.prd.ece.vwg-connect.com/api": self._homeRegionSetter[vin] = uri.split("/api")[0] self._homeRegion[vin] = self._homeRegionSetter[vin].replace( "mal-", "fal-" ) except Exception: pass async def _get_home_region(self, vin: str): if self._homeRegion.get(vin) is not None: return self._homeRegion[vin] await self._fill_home_region(vin) return self._homeRegion[vin] async def _get_home_region_setter(self, vin: str): if self._homeRegionSetter.get(vin) is not None: return self._homeRegionSetter[vin] await self._fill_home_region(vin) return self._homeRegionSetter[vin] async def _get_security_token(self, vin: str, action: str): # Challenge headers = { "User-Agent": "okhttp/3.7.0", "X-App-Version": "3.14.0", "X-App-Name": "myAudi", "Accept": "application/json", "Authorization": "Bearer " + self.vwToken.get("access_token"), } body = await self._api.request( "GET", "{homeRegionSetter}/api/rolesrights/authorization/v2/vehicles/".format( homeRegionSetter=await self._get_home_region_setter(vin.upper()) ) + vin.upper() + "/services/" + action + "/security-pin-auth-requested", headers=headers, data=None, ) secToken = body["securityPinAuthInfo"]["securityToken"] challenge = body["securityPinAuthInfo"]["securityPinTransmission"]["challenge"] # Response securityPinHash = self._generate_security_pin_hash(challenge) data = { "securityPinAuthentication": { "securityPin": { "challenge": challenge, "securityPinHash": securityPinHash, }, "securityToken": secToken, } } headers = { "User-Agent": "okhttp/3.7.0", "Content-Type": "application/json", "X-App-Version": "3.14.0", "X-App-Name": "myAudi", "Accept": "application/json", "Authorization": "Bearer " + self.vwToken.get("access_token"), } body = await self._api.request( "POST", "{homeRegionSetter}/api/rolesrights/authorization/v2/security-pin-auth-completed".format( homeRegionSetter=await self._get_home_region_setter(vin.upper()) ), headers=headers, data=json.dumps(data), ) return body["securityToken"] def _get_vehicle_action_header( self, content_type: str, security_token: str, host: Optional[str] = None ): if not host: host = ( "mal-3a.prd.eu.dp.vwg-connect.com" if self._country in {"DE", "US"} else "msg.volkswagen.de" ) headers = { "User-Agent": AudiAPI.HDR_USER_AGENT, "Host": host, "X-App-Version": AudiAPI.HDR_XAPP_VERSION, "X-App-Name": "myAudi", "Authorization": "Bearer " + self.vwToken.get("access_token"), "Accept-charset": "UTF-8", "Content-Type": content_type, "Accept": "application/json, application/vnd.vwg.mbb.ChargerAction_v1_0_0+xml,application/vnd.volkswagenag.com-error-v1+xml,application/vnd.vwg.mbb.genericError_v1_0_2+xml, application/vnd.vwg.mbb.RemoteStandheizung_v2_0_0+xml, application/vnd.vwg.mbb.genericError_v1_0_2+xml,application/vnd.vwg.mbb.RemoteLockUnlock_v1_0_0+xml,*/*", } if security_token: headers["x-securityToken"] = security_token return headers def __build_url( self, base_url: str, path_and_query: str, **path_and_query_kwargs: dict ): action_path = path_and_query.format(**path_and_query_kwargs) return base_url.rstrip("/") + "/" + action_path.lstrip("/") def __get_cariad_url(self, path_and_query: str, **path_and_query_kwargs: dict): base_url = "https://{region}.bff.cariad.digital".format( region="emea" if self._country.upper() != "US" else "na" ) return self.__build_url(base_url, path_and_query, **path_and_query_kwargs) def __get_cariad_url_for_vin( self, vin: str, path_and_query: str, **path_and_query_kwargs: dict ): base_url = self.__get_cariad_url("/vehicle/v1/vehicles/{vin}", vin=vin.upper()) return self.__build_url(base_url, path_and_query, **path_and_query_kwargs) async def set_vehicle_lock(self, vin: str, lock: bool): security_token = await self._get_security_token( vin, "rlu_v1/operations/" + ("LOCK" if lock else "UNLOCK") ) # deprecated data removed on 24Mar2025 # data = '{action}'.format( # action="lock" if lock else "unlock" # ) data = None headers = self._get_vehicle_action_header( "application/vnd.vwg.mbb.RemoteLockUnlock_v1_0_0+xml", security_token ) res = await self._api.request( "POST", "{homeRegionSetter}/api/bs/rlu/v1/vehicles/{vin}/{action}".format( homeRegionSetter=await self._get_home_region_setter(vin.upper()), vin=vin.upper(), action="lock" if lock else "unlock", ), headers=headers, data=data, ) checkUrl = "{homeRegionSetter}/api/bs/rlu/v1/vehicles/{vin}/requests/{requestId}/status".format( homeRegionSetter=await self._get_home_region_setter(vin.upper()), vin=vin.upper(), requestId=res["rluActionResponse"]["requestId"], ) await self.check_request_succeeded( checkUrl, "lock vehicle" if lock else "unlock vehicle", REQUEST_SUCCESSFUL, REQUEST_FAILED, "requestStatusResponse.status", ) async def set_battery_charger(self, vin: str, start: bool, timer: bool): if start and timer: data = {"preferredChargeMode": "timer"} elif start: data = {"preferredChargeMode": "manual"} else: raise NotImplementedError( "The 'Stop Charger' service is deprecated and will be removed in a future release." ) data = json.dumps(data) headers = {"Authorization": "Bearer " + self._bearer_token_json["access_token"]} await self._api.request( "PUT", self.__get_cariad_url_for_vin(vin, "charging/mode"), headers=headers, data=data, ) # checkUrl = "{homeRegion}/fs-car/bs/batterycharge/v1/{type}/{country}/vehicles/{vin}/charger/actions/{actionid}".format( # homeRegion=await self._get_home_region(vin.upper()), # type=self._type, # country=self._country, # vin=vin.upper(), # actionid=res["action"]["actionId"], # ) # await self.check_request_succeeded( # checkUrl, # "start charger" if start else "stop charger", # SUCCEEDED, # FAILED, # "action.actionState", # ) async def set_target_state_of_charge(self, vin: str, target_soc: int): """Set the target state of charge (battery percentage).""" if not (20 <= target_soc <= 100): raise ValueError( "Target state of charge must be between 20 and 100 percent" ) # Use Cariad BFF API (requires API level 1) headers = {"Authorization": "Bearer " + self._bearer_token_json["access_token"]} data = {"targetSOC_pct": target_soc} await self._api.request( "PUT", self.__get_cariad_url_for_vin(vin, "charging/settings"), headers=headers, data=json.dumps(data), ) async def set_climatisation(self, vin: str, start: bool): api_level = self._api_level country = self._country if start: raise NotImplementedError( "The 'Start Climatisation (Legacy)' service is deprecated and no longer functional. " "Please use the 'Start Climate Control' service instead." ) # data = '{"action":{"type": "startClimatisation","settings": {"targetTemperature": 2940,"climatisationWithoutHVpower": true,"heaterSource": "electric","climaterElementSettings": {"isClimatisationAtUnlock": false, "isMirrorHeatingEnabled": true,}}}}' else: if api_level == 0: data = '{"action":{"type": "stopClimatisation"}}' if country == "US": headers = self._get_vehicle_action_header("application/json", None) res = await self._api.request( "POST", "https://mal-3a.prd.eu.dp.vwg-connect.com/api/bs/climatisation/v1/vehicles/{vin}/climater/actions".format( vin=vin.upper(), ), headers=headers, data=data, ) checkUrl = "https://mal-3a.prd.eu.dp.vwg-connect.com/api/bs/climatisation/v1/vehicles/{vin}/climater/actions/{actionid}".format( vin=vin.upper(), actionid=res["action"]["actionId"], ) else: headers = self._get_vehicle_action_header( "application/json", None, "msg.volkswagen.de" ) res = await self._api.request( "POST", "{homeRegion}/fs-car/bs/climatisation/v1/{type}/{country}/vehicles/{vin}/climater/actions".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ), headers=headers, data=data, ) checkUrl = "{homeRegion}/fs-car/bs/climatisation/v1/{type}/{country}/vehicles/{vin}/climater/actions/{actionid}".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), actionid=res["action"]["actionId"], ) await self.check_request_succeeded( checkUrl, "stop climatisation", SUCCEEDED, FAILED, "action.actionState", ) elif api_level == 1: data = None headers = { "Authorization": "Bearer " + self._bearer_token_json["access_token"] } res = await self._api.request( "POST", self.__get_cariad_url_for_vin(vin, "climatisation/stop"), headers=headers, data=data, ) # checkUrl = "https://emea.bff.cariad.digital/vehicle/v1/vehicles/{vin}/pendingrequests".format( # vin=vin.upper(), # actionid=res["action"]["actionId"], # ) # await self.check_request_succeeded( # checkUrl, # "startClimatisation", # SUCCEEDED, # FAILED, # "action.actionState", # ) async def start_climate_control( self, vin: str, temp_f: int, temp_c: int, glass_heating: bool, seat_fl: bool, seat_fr: bool, seat_rl: bool, seat_rr: bool, climatisation_at_unlock: bool = False, climatisation_mode: str = "comfort", ): api_level = self._api_level country = self._country target_temperature = None _LOGGER.debug( f"Attempting to start climate control with API Level {api_level} and country {country}." ) if api_level == 0: target_temperature = None if temp_f is not None: target_temperature = int(((temp_f - 32) * (5 / 9)) * 10 + 2731) elif temp_c is not None: target_temperature = int(temp_c * 10 + 2731) # Default Temp target_temperature = target_temperature or 2941 # Construct Zone Settings zone_settings = [ {"value": {"isEnabled": seat_fl, "position": "frontLeft"}}, {"value": {"isEnabled": seat_fr, "position": "frontRight"}}, {"value": {"isEnabled": seat_rl, "position": "rearLeft"}}, {"value": {"isEnabled": seat_rr, "position": "rearRight"}}, ] data = { "action": { "type": "startClimatisation", "settings": { "targetTemperature": target_temperature, "climatisationWithoutHVpower": True, "heaterSource": "electric", "climaterElementSettings": { "isClimatisationAtUnlock": climatisation_at_unlock, "isMirrorHeatingEnabled": glass_heating, "zoneSettings": {"zoneSetting": zone_settings}, }, }, } } data = json.dumps(data) if country == "US": headers = self._get_vehicle_action_header("application/json", None) res = await self._api.request( "POST", "https://mal-3a.prd.eu.dp.vwg-connect.com/api/bs/climatisation/v1/vehicles/{vin}/climater/actions".format( vin=vin.upper(), ), headers=headers, data=data, ) checkUrl = "https://mal-3a.prd.eu.dp.vwg-connect.com/api/bs/climatisation/v1/vehicles/{vin}/climater/actions/{actionid}".format( vin=vin.upper(), actionid=res["action"]["actionId"], ) else: headers = self._get_vehicle_action_header( "application/json", None, "msg.volkswagen.de" ) res = await self._api.request( "POST", "{homeRegion}/fs-car/bs/climatisation/v1/{type}/{country}/vehicles/{vin}/climater/actions".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ), headers=headers, data=data, ) checkUrl = "{homeRegion}/fs-car/bs/climatisation/v1/{type}/{country}/vehicles/{vin}/climater/actions/{actionid}".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), actionid=res["action"]["actionId"], ) await self.check_request_succeeded( checkUrl, "startClimatisation", SUCCEEDED, FAILED, "action.actionState", ) elif api_level == 1: if temp_f is not None: target_temperature = int((temp_f - 32) * (5 / 9)) elif temp_c is not None: target_temperature = int(temp_c) target_temperature = target_temperature or 21 data = { "climatisationMode": climatisation_mode, "targetTemperature": target_temperature, "targetTemperatureUnit": "celsius", "climatisationWithoutExternalPower": True, "climatizationAtUnlock": climatisation_at_unlock, "windowHeatingEnabled": glass_heating, "zoneFrontLeftEnabled": seat_fl, "zoneFrontRightEnabled": seat_fr, "zoneRearLeftEnabled": seat_rl, "zoneRearRightEnabled": seat_rr, } data = json.dumps(data) headers = { "Authorization": "Bearer " + self._bearer_token_json["access_token"] } res = await self._api.request( "POST", self.__get_cariad_url_for_vin(vin, "climatisation/start"), headers=headers, data=data, ) # checkUrl = "https://emea.bff.cariad.digital/vehicle/v1/vehicles/{vin}/pendingrequests".format( # vin=vin.upper(), # actionid=res["action"]["actionId"], # ) # await self.check_request_succeeded( # checkUrl, # "startClimatisation", # SUCCEEDED, # FAILED, # "action.actionState", # ) async def set_window_heating(self, vin: str, start: bool): data = '{action}'.format( action="startWindowHeating" if start else "stopWindowHeating" ) headers = self._get_vehicle_action_header( "application/vnd.vwg.mbb.ClimaterAction_v1_0_0+xml", None ) res = await self._api.request( "POST", "{homeRegion}/fs-car/bs/climatisation/v1/{type}/{country}/vehicles/{vin}/climater/actions".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), ), headers=headers, data=data, ) checkUrl = "{homeRegion}/fs-car/bs/climatisation/v1/{type}/{country}/vehicles/{vin}/climater/actions/{actionid}".format( homeRegion=await self._get_home_region(vin.upper()), type=self._type, country=self._country, vin=vin.upper(), actionid=res["action"]["actionId"], ) await self.check_request_succeeded( checkUrl, "start window heating" if start else "stop window heating", SUCCEEDED, FAILED, "action.actionState", ) async def set_pre_heater( self, vin: str, activate: bool, duration: Optional[int] = None ): if activate: if not duration: duration = 30 data = { "duration_min": int(duration), "spin": self._spin, } data = json.dumps(data) else: data = None headers = { "Accept": "application/json", "Accept-charset": "utf-8", "Authorization": "Bearer " + self._bearer_token_json["access_token"], "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/json; charset=utf-8", "Accept-encoding": "gzip", } res = await self._api.request( "POST", self.__get_cariad_url_for_vin( vin, "auxiliaryheating/{action}", action="start" if activate else "stop" ), headers=headers, data=data, ) await self.check_bff_request_succeeded(vin, res["data"]["requestID"]) async def check_bff_request_succeeded(self, vin: str, request_id: str): headers = { "Accept": "application/json", "Accept-charset": "utf-8", "Authorization": "Bearer " + self._bearer_token_json["access_token"], "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/json; charset=utf-8", "Accept-encoding": "gzip", } for _ in range(MAX_RESPONSE_ATTEMPTS): await asyncio.sleep(REQUEST_STATUS_SLEEP) res = await self._api.request( "GET", "https://{homeRegion}.bff.cariad.digital/vehicle/v1/vehicles/{vin}/pendingrequests".format( homeRegion="na" if self._country.upper() == "US" else "emea", vin=vin.upper(), ), headers=headers, data=None, ) for pending_request in res["data"]: if pending_request["id"] == request_id: if pending_request["status"] == "in_progress": break # continue waiting if pending_request["status"] == "successful": return raise Exception( "Request {} reached unexpected status {}".format( request_id, pending_request["status"] ) ) raise Exception("Request {} timed out".format(request_id)) async def check_request_succeeded( self, url: str, action: str, successCode: str, failedCode: str, path: str ): for _ in range(MAX_RESPONSE_ATTEMPTS): await asyncio.sleep(REQUEST_STATUS_SLEEP) self._api.use_token(self.vwToken) res = await self._api.get(url) status = get_attr(res, path) if status is None or (failedCode is not None and status == failedCode): raise Exception( "Cannot {action}, return code '{code}'".format( action=action, code=status ) ) if status == successCode: return raise Exception("Cannot {action}, operation timed out".format(action=action)) # TR/2022-12-20: New secret for X_QMAuth def _calculate_X_QMAuth(self): # Calculate X-QMAuth value gmtime_100sec = int( (datetime.utcnow() - datetime(1970, 1, 1)).total_seconds() / 100 ) xqmauth_secret = bytes( [ 26, 256 - 74, 256 - 103, 37, 256 - 84, 23, 256 - 102, 256 - 86, 78, 256 - 125, 256 - 85, 256 - 26, 113, 256 - 87, 71, 109, 23, 100, 24, 256 - 72, 91, 256 - 41, 6, 256 - 15, 67, 108, 256 - 95, 91, 256 - 26, 71, 256 - 104, 256 - 100, ] ) xqmauth_val = hmac.new( xqmauth_secret, str(gmtime_100sec).encode("ascii", "ignore"), digestmod="sha256", ).hexdigest() # v1:01da27b0:fbdb6e4ba3109bc68040cb83f380796f4d3bb178a626c4cc7e166815b806e4b5 return "v1:01da27b0:" + xqmauth_val # TR/2021-12-01: Refresh token before it expires # returns True when refresh was required and successful, otherwise False async def refresh_token_if_necessary(self, elapsed_sec: int) -> bool: if self.mbboauthToken is None: return False if "refresh_token" not in self.mbboauthToken: return False if "expires_in" not in self.mbboauthToken: return False if (elapsed_sec + 5 * 60) < self.mbboauthToken["expires_in"]: # refresh not needed now return False try: headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/x-www-form-urlencoded", "X-Client-ID": self.xclientId, } mbboauth_refresh_data = { "grant_type": "refresh_token", "token": self.mbboauthToken["refresh_token"], "scope": "sc2:fal", # "vin": vin, << App uses a dedicated VIN here, but it works without, don't know } encoded_mbboauth_refresh_data = urlencode( mbboauth_refresh_data, encoding="utf-8" ).replace("+", "%20") mbboauth_refresh_rsp, mbboauth_refresh_rsptxt = await self._api.request( "POST", self.mbbOAuthBaseURL + "/mobile/oauth2/v1/token", encoded_mbboauth_refresh_data, headers=headers, allow_redirects=False, rsp_wtxt=True, ) # this code is the old "vwToken" self.vwToken = json.loads(mbboauth_refresh_rsptxt) # TR/2022-02-10: If a new refresh_token is provided, save it for further refreshes if "refresh_token" in self.vwToken: self.mbboauthToken["refresh_token"] = self.vwToken["refresh_token"] # hdr headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "X-QMAuth": self._calculate_X_QMAuth(), "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/x-www-form-urlencoded", } # IDK token request data tokenreq_data = { "client_id": self._client_id, "grant_type": "refresh_token", "refresh_token": self._bearer_token_json.get("refresh_token"), "response_type": "token id_token", } # IDK token request encoded_tokenreq_data = urlencode(tokenreq_data, encoding="utf-8").replace( "+", "%20" ) bearer_token_rsp, bearer_token_rsptxt = await self._api.request( "POST", self._tokenEndpoint, encoded_tokenreq_data, headers=headers, allow_redirects=False, rsp_wtxt=True, ) self._bearer_token_json = json.loads(bearer_token_rsptxt) # AZS token headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "X-App-Version": AudiAPI.HDR_XAPP_VERSION, "X-App-Name": "myAudi", "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/json; charset=utf-8", } asz_req_data = { "token": self._bearer_token_json["access_token"], "grant_type": "id_token", "stage": "live", "config": "myaudi", } azs_token_rsp, azs_token_rsptxt = await self._api.request( "POST", self._authorizationServerBaseURLLive + "/token", json.dumps(asz_req_data), headers=headers, allow_redirects=False, rsp_wtxt=True, ) azs_token_json = json.loads(azs_token_rsptxt) self.audiToken = azs_token_json return True except Exception as exception: _LOGGER.error("Refresh token failed: " + str(exception)) return False # TR/2021-12-01 updated to match behaviour of Android myAudi 4.5.0 async def login_request(self, user: str, password: str): self._api.use_token(None) self._api.set_xclient_id(None) self.xclientId = None # get markets markets_json = await self._api.request( "GET", "https://content.app.my.audi.com/service/mobileapp/configurations/markets", None, ) if ( self._country.upper() not in markets_json["countries"]["countrySpecifications"] ): raise Exception("Country not found") self._language = markets_json["countries"]["countrySpecifications"][ self._country.upper() ]["defaultLanguage"] # Dynamic configuration URLs marketcfg_url = "https://content.app.my.audi.com/service/mobileapp/configurations/market/{c}/{l}?v=4.23.1".format( c=self._country, l=self._language ) openidcfg_url = self.__get_cariad_url("/login/v1/idk/openid-configuration") # get market config marketcfg_json = await self._api.request("GET", marketcfg_url, None) # use dynamic config from marketcfg self._client_id = "09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com" if "idkClientIDAndroidLive" in marketcfg_json: self._client_id = marketcfg_json["idkClientIDAndroidLive"] self._authorizationServerBaseURLLive = self.__get_cariad_url("/login/v1/audi") if "authorizationServerBaseURLLive" in marketcfg_json: self._authorizationServerBaseURLLive = marketcfg_json[ "myAudiAuthorizationServerProxyServiceURLProduction" ] self.mbbOAuthBaseURL = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth" if "mbbOAuthBaseURLLive" in marketcfg_json: self.mbbOAuthBaseURL = marketcfg_json["mbbOAuthBaseURLLive"] # get openId config openidcfg_json = await self._api.request("GET", openidcfg_url, None) # use dynamic config from openId config authorization_endpoint = "https://identity.vwgroup.io/oidc/v1/authorize" if "authorization_endpoint" in openidcfg_json: authorization_endpoint = openidcfg_json["authorization_endpoint"] self._tokenEndpoint = self.__get_cariad_url("/login/v1/idk/token") if "token_endpoint" in openidcfg_json: self._tokenEndpoint = openidcfg_json["token_endpoint"] # revocation_endpoint = self.__get_cariad_base_url("/login/v1/idk/revoke") # if "revocation_endpoint" in openidcfg_json: # revocation_endpoint = openidcfg_json["revocation_endpoint"] # generate code_challenge code_verifier = str(base64.urlsafe_b64encode(os.urandom(32)), "utf-8").strip( "=" ) code_challenge = str( base64.urlsafe_b64encode( sha256(code_verifier.encode("ascii", "ignore")).digest() ), "utf-8", ).strip("=") code_challenge_method = "S256" # state = str(uuid.uuid4()) nonce = str(uuid.uuid4()) # login page headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "X-App-Version": AudiAPI.HDR_XAPP_VERSION, "X-App-Name": "myAudi", "User-Agent": AudiAPI.HDR_USER_AGENT, } idk_data = { "response_type": "code", "client_id": self._client_id, "redirect_uri": "myaudi:///", "scope": "address profile badge birthdate birthplace nationalIdentifier nationality profession email vin phone nickname name picture mbb gallery openid", "state": state, "nonce": nonce, "prompt": "login", "code_challenge": code_challenge, "code_challenge_method": code_challenge_method, "ui_locales": "de-de de", } idk_rsp, idk_rsptxt = await self._api.request( "GET", authorization_endpoint, None, headers=headers, params=idk_data, rsp_wtxt=True, ) # form_data with email submit_data = self.get_hidden_html_input_form_data(idk_rsptxt, {"email": user}) submit_url = self.get_post_url(idk_rsptxt, authorization_endpoint) # send email email_rsp, email_rsptxt = await self._api.request( "POST", submit_url, submit_data, headers=headers, cookies=idk_rsp.cookies, allow_redirects=True, rsp_wtxt=True, ) # form_data with password # 2022-01-29: new HTML response uses a js two build the html form data + button. # Therefore it's not possible to extract hmac and other form data. # --> extract hmac from embedded js snippet. regex_res = re.findall('"hmac"\\s*:\\s*"[0-9a-fA-F]+"', email_rsptxt) if regex_res: submit_url = submit_url.replace("identifier", "authenticate") submit_data["hmac"] = regex_res[0].split(":")[1].strip('"') submit_data["password"] = password else: submit_data = self.get_hidden_html_input_form_data( email_rsptxt, {"password": password} ) submit_url = self.get_post_url(email_rsptxt, submit_url) # send password pw_rsp, pw_rsptxt = await self._api.request( "POST", submit_url, submit_data, headers=headers, cookies=idk_rsp.cookies, allow_redirects=False, rsp_wtxt=True, ) # forward1 after pwd fwd1_rsp, fwd1_rsptxt = await self._api.request( "GET", pw_rsp.headers["Location"], None, headers=headers, cookies=idk_rsp.cookies, allow_redirects=False, rsp_wtxt=True, ) # forward2 after pwd fwd2_rsp, fwd2_rsptxt = await self._api.request( "GET", fwd1_rsp.headers["Location"], None, headers=headers, cookies=idk_rsp.cookies, allow_redirects=False, rsp_wtxt=True, ) # get tokens codeauth_rsp, codeauth_rsptxt = await self._api.request( "GET", fwd2_rsp.headers["Location"], None, headers=headers, cookies=fwd2_rsp.cookies, allow_redirects=False, rsp_wtxt=True, ) authcode_parsed = urlparse( codeauth_rsp.headers["Location"][len("myaudi:///?") :] ) authcode_strings = parse_qs(authcode_parsed.path) # hdr headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "X-QMAuth": self._calculate_X_QMAuth(), "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/x-www-form-urlencoded", } # IDK token request data tokenreq_data = { "client_id": self._client_id, "grant_type": "authorization_code", "code": authcode_strings["code"][0], "redirect_uri": "myaudi:///", "response_type": "token id_token", "code_verifier": code_verifier, } # IDK token request encoded_tokenreq_data = urlencode(tokenreq_data, encoding="utf-8").replace( "+", "%20" ) bearer_token_rsp, bearer_token_rsptxt = await self._api.request( "POST", self._tokenEndpoint, encoded_tokenreq_data, headers=headers, allow_redirects=False, rsp_wtxt=True, ) self._bearer_token_json = json.loads(bearer_token_rsptxt) # AZS token headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "X-App-Version": AudiAPI.HDR_XAPP_VERSION, "X-App-Name": "myAudi", "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/json; charset=utf-8", } asz_req_data = { "token": self._bearer_token_json["access_token"], "grant_type": "id_token", "stage": "live", "config": "myaudi", } azs_token_rsp, azs_token_rsptxt = await self._api.request( "POST", self._authorizationServerBaseURLLive + "/token", json.dumps(asz_req_data), headers=headers, allow_redirects=False, rsp_wtxt=True, ) azs_token_json = json.loads(azs_token_rsptxt) self.audiToken = azs_token_json # mbboauth client register headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/json; charset=utf-8", } mbboauth_reg_data = { "client_name": "SM-A405FN", "platform": "google", "client_brand": "Audi", "appName": "myAudi", "appVersion": AudiAPI.HDR_XAPP_VERSION, "appId": "de.myaudi.mobile.assistant", } mbboauth_client_reg_rsp, mbboauth_client_reg_rsptxt = await self._api.request( "POST", self.mbbOAuthBaseURL + "/mobile/register/v1", json.dumps(mbboauth_reg_data), headers=headers, allow_redirects=False, rsp_wtxt=True, ) mbboauth_client_reg_json = json.loads(mbboauth_client_reg_rsptxt) self.xclientId = mbboauth_client_reg_json["client_id"] self._api.set_xclient_id(self.xclientId) # mbboauth auth headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/x-www-form-urlencoded", "X-Client-ID": self.xclientId, } mbboauth_auth_data = { "grant_type": "id_token", "token": self._bearer_token_json["id_token"], "scope": "sc2:fal", } encoded_mbboauth_auth_data = urlencode( mbboauth_auth_data, encoding="utf-8" ).replace("+", "%20") mbboauth_auth_rsp, mbboauth_auth_rsptxt = await self._api.request( "POST", self.mbbOAuthBaseURL + "/mobile/oauth2/v1/token", encoded_mbboauth_auth_data, headers=headers, allow_redirects=False, rsp_wtxt=True, ) mbboauth_auth_json = json.loads(mbboauth_auth_rsptxt) # store token and expiration time self.mbboauthToken = mbboauth_auth_json # mbboauth refresh (app immediately refreshes the token) headers = { "Accept": "application/json", "Accept-Charset": "utf-8", "User-Agent": AudiAPI.HDR_USER_AGENT, "Content-Type": "application/x-www-form-urlencoded", "X-Client-ID": self.xclientId, } mbboauth_refresh_data = { "grant_type": "refresh_token", "token": mbboauth_auth_json["refresh_token"], "scope": "sc2:fal", # "vin": vin, << App uses a dedicated VIN here, but it works without, don't know } encoded_mbboauth_refresh_data = urlencode( mbboauth_refresh_data, encoding="utf-8" ).replace("+", "%20") mbboauth_refresh_rsp, mbboauth_refresh_rsptxt = await self._api.request( "POST", self.mbbOAuthBaseURL + "/mobile/oauth2/v1/token", encoded_mbboauth_refresh_data, headers=headers, allow_redirects=False, cookies=mbboauth_client_reg_rsp.cookies, rsp_wtxt=True, ) # this code is the old "vwToken" self.vwToken = json.loads(mbboauth_refresh_rsptxt) def _generate_security_pin_hash(self, challenge): if self._spin is None: raise Exception("sPin is required to perform this action") pin = to_byte_array(self._spin) byteChallenge = to_byte_array(challenge) b = bytes(pin + byteChallenge) return sha512(b).hexdigest().upper() async def _emulate_browser( self, reply: BrowserLoginResponse, form_data: Dict[str, str] ) -> BrowserLoginResponse: # The reply redirects to the login page login_location = reply.get_location() page_reply = await self._api.get(login_location, raw_contents=True) # Now parse the html body and extract the target url, csrf token and other required parameters html = BeautifulSoup(page_reply, "html.parser") form_tag = html.find("form") form_inputs = html.find_all("input", attrs={"type": "hidden"}) for form_input in form_inputs: name = form_input.get("name") form_data[name] = form_input.get("value") # Extract the target url action = form_tag.get("action") if action.startswith("http"): # Absolute url username_post_url = action elif action.startswith("/"): # Relative to domain username_post_url = BrowserLoginResponse.to_absolute(login_location, action) else: raise RequestException("Unknown form action: " + action) headers = {"referer": login_location} reply = await self._api.post( username_post_url, form_data, headers=headers, use_json=False, raw_reply=True, allow_redirects=False, ) return BrowserLoginResponse(reply, username_post_url) ================================================ FILE: custom_components/audiconnect/binary_sensor.py ================================================ """Support for Audi Connect sensors.""" import logging from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import CONF_USERNAME from .audi_entity import AudiEntity from .const import DOMAIN _LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way.""" async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] account = config_entry.data.get(CONF_USERNAME) audiData = hass.data[DOMAIN][account] for config_vehicle in audiData.config_vehicles: for binary_sensor in config_vehicle.binary_sensors: sensors.append(AudiSensor(config_vehicle, binary_sensor)) async_add_entities(sensors) class AudiSensor(AudiEntity, BinarySensorEntity): """Representation of an Audi sensor.""" @property def is_on(self): """Return True if the binary sensor is on.""" return self._instrument.is_on @property def device_class(self): """Return the device_class of this sensor.""" return self._instrument.device_class @property def entity_category(self): """Return the entity_category.""" return self._instrument.entity_category ================================================ FILE: custom_components/audiconnect/config_flow.py ================================================ from collections import OrderedDict import logging import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, CONF_REGION, CONF_SCAN_INTERVAL, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.core import callback from .audi_connect_account import AudiConnectAccount from .const import ( DOMAIN, CONF_SPIN, DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, CONF_SCAN_INITIAL, CONF_SCAN_ACTIVE, REGIONS, CONF_API_LEVEL, DEFAULT_API_LEVEL, API_LEVELS, CONF_FILTER_VINS, ) _LOGGER = logging.getLogger(__name__) @callback def configured_accounts(hass): """Return tuple of configured usernames.""" entries = hass.config_entries.async_entries(DOMAIN) if entries: return (entry.data[CONF_USERNAME] for entry in entries) return () @config_entries.HANDLERS.register(DOMAIN) class AudiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize.""" self._username = vol.UNDEFINED self._password = vol.UNDEFINED self._spin = vol.UNDEFINED self._region = vol.UNDEFINED self._scan_interval = DEFAULT_UPDATE_INTERVAL self._api_level = DEFAULT_API_LEVEL async def async_step_user(self, user_input=None): """Handle a user initiated config flow.""" errors = {} if user_input is not None: self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] self._spin = user_input.get(CONF_SPIN) self._region = REGIONS[user_input.get(CONF_REGION)] self._scan_interval = user_input[CONF_SCAN_INTERVAL] self._api_level = user_input[CONF_API_LEVEL] try: # pylint: disable=no-value-for-parameter session = async_get_clientsession(self.hass) connection = AudiConnectAccount( session=session, username=vol.Email()(self._username), password=self._password, country=self._region, spin=self._spin, api_level=self._api_level, ) if await connection.try_login(False) is False: raise Exception( "Unexpected error communicating with the Audi server" ) except vol.Invalid: errors[CONF_USERNAME] = "invalid_username" except Exception: errors["base"] = "invalid_credentials" else: if self._username in configured_accounts(self.hass): errors["base"] = "user_already_configured" else: return self.async_create_entry( title=f"{self._username}", data={ CONF_USERNAME: self._username, CONF_PASSWORD: self._password, CONF_SPIN: self._spin, CONF_REGION: self._region, CONF_SCAN_INTERVAL: self._scan_interval, CONF_API_LEVEL: self._api_level, }, ) data_schema = OrderedDict() data_schema[vol.Required(CONF_USERNAME, default=self._username)] = str data_schema[vol.Required(CONF_PASSWORD, default=self._password)] = str data_schema[vol.Optional(CONF_SPIN, default=self._spin)] = str data_schema[vol.Required(CONF_REGION, default=self._region)] = vol.In(REGIONS) data_schema[ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL) ] = int data_schema[ vol.Optional(CONF_API_LEVEL, default=API_LEVELS[DEFAULT_API_LEVEL]) ] = vol.All(vol.Coerce(int), vol.In(API_LEVELS)) return self.async_show_form( step_id="user", data_schema=vol.Schema(data_schema), errors=errors, ) async def async_step_import(self, user_input): """Import a config flow from configuration.""" username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] api_level = user_input[CONF_API_LEVEL] spin = None if user_input.get(CONF_SPIN): spin = user_input[CONF_SPIN] region = "DE" if user_input.get(CONF_REGION): region = REGIONS[user_input.get(CONF_REGION)] scan_interval = DEFAULT_UPDATE_INTERVAL if user_input.get(CONF_SCAN_INTERVAL): scan_interval = user_input[CONF_SCAN_INTERVAL] if scan_interval < MIN_UPDATE_INTERVAL: scan_interval = MIN_UPDATE_INTERVAL try: session = async_get_clientsession(self.hass) connection = AudiConnectAccount( session=session, username=username, password=password, country=region, spin=spin, api_level=api_level, ) if await connection.try_login(False) is False: raise Exception("Unexpected error communicating with the Audi server") except Exception: _LOGGER.error("Invalid credentials for %s", username) return self.async_abort(reason="invalid_credentials") return self.async_create_entry( title=f"{username} (from configuration)", data={ CONF_USERNAME: username, CONF_PASSWORD: password, CONF_SPIN: spin, CONF_REGION: region, CONF_SCAN_INTERVAL: scan_interval, CONF_API_LEVEL: api_level, }, ) @staticmethod @callback def async_get_options_flow(config_entry): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) class OptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry): self._config_entry: config_entries.ConfigEntry = config_entry _LOGGER.debug( "Initializing options flow for audiconnect: %s", config_entry.title ) async def async_step_init(self, user_input=None): _LOGGER.debug( "Options flow initiated for audiconnect: %s", self._config_entry.title ) if user_input is not None: _LOGGER.debug("Received user input for options: %s", user_input) return self.async_create_entry(title="", data=user_input) current_scan_interval = self._config_entry.options.get( CONF_SCAN_INTERVAL, self._config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_UPDATE_INTERVAL), ) current_api_level = self._config_entry.options.get( CONF_API_LEVEL, self._config_entry.data.get(CONF_API_LEVEL, API_LEVELS[DEFAULT_API_LEVEL]), ) current_filter_vins = self._config_entry.options.get( CONF_FILTER_VINS, self._config_entry.data.get(CONF_FILTER_VINS, ""), ) _LOGGER.debug( "Retrieved current scan interval for audiconnect %s: %s minutes", self._config_entry.title, current_scan_interval, ) _LOGGER.debug( "Preparing options form for %s with default scan interval: %s minutes, initial scan: %s, active scan: %s", self._config_entry.title, current_scan_interval, self._config_entry.options.get(CONF_SCAN_INITIAL, True), self._config_entry.options.get(CONF_SCAN_ACTIVE, True), ) return self.async_show_form( step_id="init", data_schema=vol.Schema( { vol.Required( CONF_SCAN_INITIAL, default=self._config_entry.options.get(CONF_SCAN_INITIAL, True), ): bool, vol.Required( CONF_SCAN_ACTIVE, default=self._config_entry.options.get(CONF_SCAN_ACTIVE, True), ): bool, vol.Optional( CONF_SCAN_INTERVAL, default=current_scan_interval ): vol.All(vol.Coerce(int), vol.Clamp(min=MIN_UPDATE_INTERVAL)), vol.Optional(CONF_API_LEVEL, default=current_api_level): vol.All( vol.Coerce(int), vol.In(API_LEVELS) ), vol.Optional(CONF_FILTER_VINS, default=current_filter_vins): str, } ), ) ================================================ FILE: custom_components/audiconnect/const.py ================================================ DOMAIN = "audiconnect" CONF_VIN = "vin" CONF_CARNAME = "carname" CONF_ACTION = "action" CONF_CLIMATE_TEMP_F = "temp_f" CONF_CLIMATE_TEMP_C = "temp_c" CONF_CLIMATE_GLASS = "glass_heating" CONF_CLIMATE_SEAT_FL = "seat_fl" CONF_CLIMATE_SEAT_FR = "seat_fr" CONF_CLIMATE_SEAT_RL = "seat_rl" CONF_CLIMATE_SEAT_RR = "seat_rr" CONF_CLIMATE_AT_UNLOCK = "climatisation_at_unlock" CONF_CLIMATE_MODE = "climatisation_mode" CONF_SCAN_INITIAL = "scan_initial" CONF_SCAN_ACTIVE = "scan_active" CONF_API_LEVEL = "api_level" CONF_DURATION = "duration" CONF_TARGET_SOC = "target_soc" MIN_UPDATE_INTERVAL = 15 DEFAULT_UPDATE_INTERVAL = 15 UPDATE_SLEEP = 5 DEFAULT_API_LEVEL = 0 CONF_SPIN = "spin" CONF_REGION = "region" CONF_SERVICE_URL = "service_url" CONF_MUTABLE = "mutable" CONF_FILTER_VINS = "filter_vins" SIGNAL_STATE_UPDATED = "{}.updated".format(DOMAIN) TRACKER_UPDATE = f"{DOMAIN}_tracker_update" RESOURCES = [ "position", "last_update_time", "shortterm_current", "shortterm_reset", "longterm_current", "longterm_reset", "mileage", "range", "service_inspection_time", "service_inspection_distance", "service_adblue_distance", "oil_change_time", "oil_change_distance", "oil_level", "charging_state", "charging_mode", "charging_type", "energy_flow", "max_charge_current", "engine_type1", "engine_type2", "parking_light", "any_window_open", "any_door_unlocked", "any_door_open", "trunk_unlocked", "trunk_open", "hood_open", "tank_level", "state_of_charge", "remaining_charging_time", "plug_state", "sun_roof", "doors_trunk_status", "left_front_door_open", "right_front_door_open", "left_rear_door_open", "right_rear_door_open", "left_front_window_open", "right_front_window_open", "left_rear_window_open", "right_rear_window_open", "braking_status", "is_moving", ] COMPONENTS = { "sensor": "sensor", "binary_sensor": "binary_sensor", "lock": "lock", "device_tracker": "device_tracker", "switch": "switch", } REGION_EUROPE: str = "DE" REGION_CANADA: str = "CA" REGION_USA: str = "US" REGION_CHINA: str = "CN" REGIONS = { 1: REGION_EUROPE, 2: REGION_CANADA, 3: REGION_USA, 4: REGION_CHINA, } API_LEVELS = [0, 1] ================================================ FILE: custom_components/audiconnect/dashboard.py ================================================ # Utilities for integration with Home Assistant (directly or via MQTT) import logging import re from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ( PERCENTAGE, UnitOfTime, UnitOfLength, UnitOfTemperature, UnitOfPower, UnitOfElectricCurrent, EntityCategory, ) from .util import parse_datetime _LOGGER = logging.getLogger(__name__) class Instrument: def __init__( self, component, attr, name, icon=None, suggested_display_precision=None ): self._attr = attr self._component = component self._name = name self._connection = None self._vehicle = None self._icon = icon self._suggested_display_precision = suggested_display_precision def __repr__(self): return self.full_name def camel2slug(self, s): """Convert camelCase to camel_case. >>> camel2slug('fooBar') 'foo_bar' """ return re.sub("([A-Z])", "_\\1", s).lower().lstrip("_") @property def slug_attr(self): return self.camel2slug(self._attr.replace(".", "_")) def setup(self, connection, vehicle, mutable=True, **config): self._connection = connection self._vehicle = vehicle if not mutable and self.is_mutable: _LOGGER.debug("Skipping %s because mutable", self) return False if not self.is_supported: # _LOGGER.debug( # "%s (%s:%s) is not supported", self, type(self).__name__, self._attr, # ) return False # _LOGGER.debug("%s is supported", self) return True @property def component(self): return self._component @property def icon(self): return self._icon @property def name(self): return self._name @property def attr(self): return self._attr @property def suggested_display_precision(self): return self._suggested_display_precision @property def vehicle_name(self): return self._vehicle.title @property def full_name(self): return "{} {}".format(self.vehicle_name, self._name) @property def vehicle_model(self): return self._vehicle.model @property def vehicle_model_year(self): return self._vehicle.model_year @property def vehicle_model_family(self): return self._vehicle.model_family @property def vehicle_vin(self): return self._vehicle.vin @property def vehicle_csid(self): return self._vehicle.csid @property def is_mutable(self): raise NotImplementedError("Must be set") @property def is_supported(self): supported = self._attr + "_supported" if hasattr(self._vehicle, supported): return getattr(self._vehicle, supported) if hasattr(self._vehicle, self._attr): return True return False @property def str_state(self): return self.state @property def state(self): if hasattr(self._vehicle, self._attr): return getattr(self._vehicle, self._attr) return self._vehicle.get_attr(self._attr) @property def attributes(self): return {} class Sensor(Instrument): def __init__( self, attr, name, icon=None, unit=None, state_class=None, device_class=None, entity_category=None, extra_state_attributes=None, suggested_display_precision=None, ): super().__init__( component="sensor", attr=attr, name=name, icon=icon, suggested_display_precision=suggested_display_precision, ) self.device_class = device_class self._unit = unit self.state_class = state_class self.entity_category = entity_category self.extra_state_attributes = extra_state_attributes self._convert = False @property def is_mutable(self): return False @property def str_state(self): if self.unit: return "{} {}".format(self.state, self.unit) else: return "%s" % self.state @property def state(self): return super().state @property def unit(self): supported = self._attr + "_unit" if hasattr(self._vehicle, supported): return getattr(self._vehicle, supported) return self._unit class BinarySensor(Instrument): def __init__(self, attr, name, device_class=None, icon=None, entity_category=None): super().__init__(component="binary_sensor", attr=attr, name=name, icon=icon) self.device_class = device_class self.entity_category = entity_category @property def is_mutable(self): return False @property def str_state(self): if self.device_class in ["door", "window"]: return "Open" if self.state else "Closed" if self.device_class == "safety": return "Warning!" if self.state else "OK" if self.device_class == "plug": return "Charging" if self.state else "Plug removed" if self.device_class == "lock": return "Unlocked" if self.state else "Locked" if self.state is None: _LOGGER.error("Can not encode state %s:%s", self._attr, self.state) return "?" return "On" if self.state else "Off" @property def state(self): val = super().state if isinstance(val, (bool, list)): # for list (e.g. bulb_failures): # empty list (False) means no problem return bool(val) elif isinstance(val, str): return val != "Normal" return val @property def is_on(self): return self.state class Lock(Instrument): def __init__(self): super().__init__(component="lock", attr="lock", name="Door lock") @property def is_mutable(self): return True @property def str_state(self): return "Locked" if self.state else "Unlocked" @property def state(self): return self._vehicle.doors_trunk_status == "Locked" @property def is_locked(self): return self.state async def lock(self): await self._connection.set_vehicle_lock(self.vehicle_vin, True) async def unlock(self): await self._connection.set_vehicle_lock(self.vehicle_vin, False) class Switch(Instrument): def __init__(self, attr, name, icon): super().__init__(component="switch", attr=attr, name=name, icon=icon) @property def is_mutable(self): return True @property def str_state(self): return "On" if self.state else "Off" def is_on(self): return self.state def turn_on(self): pass def turn_off(self): pass class Preheater(Instrument): def __init__(self): super().__init__( component="switch", attr="preheater_active", name="Preheater", icon="mdi:radiator", ) @property def is_mutable(self): return True @property def str_state(self): return "On" if self.state else "Off" def is_on(self): return self.state async def turn_on(self): await self._connection.set_vehicle_pre_heater(self.vehicle_vin, True) async def turn_off(self): await self._connection.set_vehicle_pre_heater(self.vehicle_vin, False) class Position(Instrument): def __init__(self): super().__init__(component="device_tracker", attr="position", name="Position") @property def is_mutable(self): return False @property def state(self): state = super().state or {} return ( state.get("latitude", None), state.get("longitude", None), state.get("timestamp", None), state.get("parktime", None), ) @property def str_state(self): state = super().state or {} ts = state.get("timestamp") pt = state.get("parktime") return ( state.get("latitude", None), state.get("longitude", None), str(ts.astimezone(tz=None)) if ts else None, str(pt.astimezone(tz=None)) if pt else None, ) class TripData(Instrument): def __init__(self, attr, name): super().__init__(component="sensor", attr=attr, name=name) self.device_class = SensorDeviceClass.TIMESTAMP self.unit = None self.state_class = None self.entity_category = None @property def is_mutable(self): return False @property def str_state(self): val = super().state txt = "" if val["averageElectricEngineConsumption"] is not None: txt = "{}{}_kWh__".format(txt, val["averageElectricEngineConsumption"]) if val["averageFuelConsumption"] is not None: txt = "{}{}_ltr__".format(txt, val["averageFuelConsumption"]) return "{}{}_kmh__{}:{:02d}h_({}_m)__{}_km__{}-{}_km".format( txt, val["averageSpeed"], int(val["traveltime"] / 60), val["traveltime"] % 60, val["traveltime"], val["mileage"], val["startMileage"], val["overallMileage"], ) @property def state(self): td = super().state return parse_datetime(td["timestamp"]) @property def extra_state_attributes(self): td = super().state attr = { "averageElectricEngineConsumption": td.get( "averageElectricEngineConsumption", None ), "averageFuelConsumption": td.get("averageFuelConsumption", None), "averageSpeed": td.get("averageSpeed", None), "mileage": td.get("mileage", None), "overallMileage": td.get("overallMileage", None), "startMileage": td.get("startMileage", None), "traveltime": td.get("traveltime", None), "tripID": td.get("tripID", None), "zeroEmissionDistance": td.get("zeroEmissionDistance", None), } return attr class LastUpdate(Instrument): def __init__(self): super().__init__( component="sensor", attr="last_update_time", name="Last Update", icon="mdi:update", ) self.device_class = SensorDeviceClass.TIMESTAMP self.unit = None self.state_class = None self.entity_category = None self.extra_state_attributes = None @property def is_mutable(self): return False @property def str_state(self): ts = super().state return ts.astimezone(tz=None).isoformat() if ts else None @property def state(self): return super().state def create_instruments(): return [ Position(), LastUpdate(), TripData(attr="shortterm_current", name="ShortTerm Trip Data"), TripData(attr="shortterm_reset", name="ShortTerm Trip User Reset"), TripData(attr="longterm_current", name="LongTerm Trip Data"), TripData(attr="longterm_reset", name="LongTerm Trip User Reset"), Lock(), Preheater(), Sensor( attr="model", name="Model", icon="mdi:car-info", entity_category=EntityCategory.DIAGNOSTIC, ), Sensor( attr="mileage", name="Mileage", icon="mdi:counter", unit=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.DISTANCE, entity_category=EntityCategory.DIAGNOSTIC, suggested_display_precision=0, ), Sensor( attr="service_adblue_distance", name="AdBlue range", icon="mdi:map-marker-distance", unit=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=0, ), Sensor( attr="range", name="Range", icon="mdi:map-marker-distance", unit=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=0, ), Sensor( attr="hybrid_range", name="hybrid Range", icon="mdi:map-marker-distance", unit=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=0, ), Sensor( attr="service_inspection_time", name="Service inspection time", icon="mdi:room-service-outline", unit=UnitOfTime.DAYS, entity_category=EntityCategory.DIAGNOSTIC, ), Sensor( attr="service_inspection_distance", name="Service inspection distance", icon="mdi:room-service-outline", unit=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, entity_category=EntityCategory.DIAGNOSTIC, suggested_display_precision=0, ), Sensor( attr="oil_change_time", name="Oil change time", icon="mdi:oil", unit=UnitOfTime.DAYS, entity_category=EntityCategory.DIAGNOSTIC, ), Sensor( attr="oil_change_distance", name="Oil change distance", icon="mdi:oil", unit=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, entity_category=EntityCategory.DIAGNOSTIC, suggested_display_precision=0, ), Sensor( attr="oil_level", name="Oil level", icon="mdi:oil", unit=PERCENTAGE, ), Sensor( attr="charging_state", name="Charging state", icon="mdi:car-battery", ), Sensor( attr="charging_mode", name="Charging mode", ), Sensor( attr="charging_type", name="Charging type", ), Sensor( attr="energy_flow", name="Energy flow", ), Sensor( attr="max_charge_current", name="Max charge current", icon="mdi:current-ac", unit=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, ), Sensor( attr="primary_engine_type", name="Primary engine type", icon="mdi:engine", entity_category=EntityCategory.DIAGNOSTIC, ), Sensor( attr="secondary_engine_type", name="Secondary engine type", icon="mdi:engine", entity_category=EntityCategory.DIAGNOSTIC, ), Sensor( attr="primary_engine_range", name="Primary engine range", icon="mdi:map-marker-distance", unit=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=0, ), Sensor( attr="secondary_engine_range", name="Secondary engine range", icon="mdi:map-marker-distance", unit=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=0, ), Sensor( attr="primary_engine_range_percent", name="Primary engine Percent", icon="mdi:gauge", unit=PERCENTAGE, ), Sensor( attr="car_type", name="Car Type", icon="mdi:car-info", entity_category=EntityCategory.DIAGNOSTIC, ), Sensor( attr="secondary_engine_range_percent", name="Secondary engine Percent", icon="mdi:gauge", unit=PERCENTAGE, ), Sensor( attr="charging_power", name="Charging power", icon="mdi:flash", unit=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, ), Sensor( attr="actual_charge_rate", name="Charging rate", icon="mdi:electron-framework", ), Sensor( attr="tank_level", name="Tank level", icon="mdi:gauge", unit=PERCENTAGE, ), Sensor( attr="state_of_charge", name="State of charge", unit=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), Sensor( attr="remaining_charging_time", name="Remaining charge time", icon="mdi:battery-charging", ), Sensor( attr="charging_complete_time", name="Charging Complete Time", icon="mdi:battery-charging", device_class=SensorDeviceClass.TIMESTAMP, ), Sensor( attr="target_state_of_charge", name="Target State of charge", icon="mdi:ev-station", unit=PERCENTAGE, ), BinarySensor( attr="plug_state", name="Plug state", icon="mdi:ev-plug-type1", device_class=BinarySensorDeviceClass.PLUG, ), BinarySensor( attr="plug_lock_state", name="Plug Lock state", icon="mdi:ev-plug-type1", device_class=BinarySensorDeviceClass.LOCK, ), Sensor( attr="external_power", name="External Power", icon="mdi:ev-station", ), Sensor( attr="plug_led_color", name="Plug LED Color", icon="mdi:ev-plug-type1", entity_category=EntityCategory.DIAGNOSTIC, ), Sensor( attr="doors_trunk_status", name="Doors/trunk state", icon="mdi:car-door", ), Sensor( attr="climatisation_state", name="Climatisation state", icon="mdi:air-conditioner", ), Sensor( attr="outdoor_temperature", name="Outdoor Temperature", icon="mdi:temperature-celsius", unit=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), Sensor( attr="park_time", name="Park Time", icon="mdi:car-clock", device_class=SensorDeviceClass.TIMESTAMP, ), Sensor( attr="remaining_climatisation_time", name="Remaining Climatisation Time", icon="mdi:fan-clock", unit=UnitOfTime.MINUTES, ), BinarySensor( attr="glass_surface_heating", name="Glass Surface Heating", icon="mdi:car-defrost-front", device_class=BinarySensorDeviceClass.RUNNING, ), Sensor( attr="preheater_duration", name="Preheater runtime", icon="mdi:clock", unit=UnitOfTime.MINUTES, ), Sensor( attr="preheater_remaining", name="Preheater remaining", icon="mdi:clock", unit=UnitOfTime.MINUTES, ), BinarySensor( attr="sun_roof", name="Sun roof", device_class=BinarySensorDeviceClass.WINDOW, ), BinarySensor( attr="roof_cover", name="Roof Cover", device_class=BinarySensorDeviceClass.WINDOW, ), BinarySensor( attr="parking_light", name="Parking light", device_class=BinarySensorDeviceClass.SAFETY, icon="mdi:lightbulb", entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="any_window_open", name="Windows", device_class=BinarySensorDeviceClass.WINDOW, ), BinarySensor( attr="any_door_unlocked", name="Doors lock", device_class=BinarySensorDeviceClass.LOCK, ), BinarySensor( attr="any_door_open", name="Doors", device_class=BinarySensorDeviceClass.DOOR, ), BinarySensor( attr="trunk_unlocked", name="Trunk lock", device_class=BinarySensorDeviceClass.LOCK, ), BinarySensor( attr="trunk_open", name="Trunk", device_class=BinarySensorDeviceClass.DOOR, ), BinarySensor( attr="hood_open", name="Hood", device_class=BinarySensorDeviceClass.DOOR, ), BinarySensor( attr="left_front_door_open", name="Left front door", device_class=BinarySensorDeviceClass.DOOR, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="right_front_door_open", name="Right front door", device_class=BinarySensorDeviceClass.DOOR, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="left_rear_door_open", name="Left rear door", device_class=BinarySensorDeviceClass.DOOR, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="right_rear_door_open", name="Right rear door", device_class=BinarySensorDeviceClass.DOOR, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="left_front_window_open", name="Left front window", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="right_front_window_open", name="Right front window", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="left_rear_window_open", name="Left rear window", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="right_rear_window_open", name="Right rear window", device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="braking_status", name="Braking status", device_class=BinarySensorDeviceClass.SAFETY, icon="mdi:car-brake-abs", ), BinarySensor( attr="oil_level_binary", name="Oil Level Binary", icon="mdi:oil", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensor( attr="is_moving", name="Is moving", icon="mdi:motion-outline", device_class=BinarySensorDeviceClass.MOVING, entity_category=EntityCategory.DIAGNOSTIC, ), ] class Dashboard: def __init__(self, connection, vehicle, **config): self.instruments = [ instrument for instrument in create_instruments() if instrument.setup(connection, vehicle, **config) ] ================================================ FILE: custom_components/audiconnect/device_tracker.py ================================================ """Support for tracking an Audi.""" import logging from typing import Any from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, TRACKER_UPDATE _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Audi device tracker entities.""" account = config_entry.data.get(CONF_USERNAME) audi_data = hass.data.get(DOMAIN, {}).get(account) if not audi_data: _LOGGER.error("Audi Connect data not found for account: %s", account) return if "devices" not in hass.data[DOMAIN]: hass.data[DOMAIN]["devices"] = set() async def add_vehicle_tracker(instrument: Any) -> None: """Add a tracker entity when data is received.""" unique_id = instrument.full_name if unique_id in hass.data[DOMAIN]["devices"]: return _LOGGER.debug("Adding new AudiDeviceTracker: %s", unique_id) hass.data[DOMAIN]["devices"].add(unique_id) async_add_entities([AudiDeviceTracker(config_entry, instrument)], True) async_dispatcher_connect(hass, TRACKER_UPDATE, add_vehicle_tracker) for config_vehicle in getattr(audi_data, "config_vehicles", []): for tracker in getattr(config_vehicle, "device_trackers", []): async_dispatcher_send(hass, TRACKER_UPDATE, tracker) class AudiDeviceTracker(TrackerEntity): """Representation of an Audi device tracker.""" _attr_icon = "mdi:car" _attr_should_poll = False _attr_source_type = SourceType.GPS def __init__(self, config_entry: ConfigEntry, instrument: Any) -> None: """Initialize tracker.""" self._instrument = instrument self._config_entry = config_entry self._attr_unique_id = instrument.full_name self._latitude = None self._longitude = None self._update_state_from_instrument() self._attr_device_info = { "identifiers": {(DOMAIN, instrument.vehicle_name)}, "manufacturer": "Audi", "name": instrument.vehicle_name, "model": getattr(instrument, "vehicle_model", None), } def _update_state_from_instrument(self) -> None: """Update internal lat/lon state.""" state = getattr(self._instrument, "state", None) if isinstance(state, (list, tuple)) and len(state) >= 2: try: self._latitude = float(state[0]) self._longitude = float(state[1]) except (ValueError, TypeError): _LOGGER.warning("Invalid GPS coordinates: %s", state) self._latitude = self._longitude = None else: _LOGGER.debug("Missing or invalid state for: %s", self._attr_unique_id) @property def latitude(self) -> float | None: return self._latitude @property def longitude(self) -> float | None: return self._longitude @property def name(self) -> str: return f"{self._instrument.vehicle_name} {self._instrument.name}" @property def extra_state_attributes(self) -> dict[str, Any]: attrs = dict(getattr(self._instrument, "attributes", {})) attrs.update( { "model": f"{getattr(self._instrument, 'vehicle_model', 'Unknown')}/{self._instrument.vehicle_name}", "model_year": getattr(self._instrument, "vehicle_model_year", None), "model_family": getattr(self._instrument, "vehicle_model_family", None), "csid": getattr(self._instrument, "vehicle_csid", None), "vin": getattr(self._instrument, "vehicle_vin", None), } ) return {k: v for k, v in attrs.items() if v is not None} async def async_added_to_hass(self) -> None: """Register update dispatcher.""" await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, TRACKER_UPDATE, self._async_receive_data ) ) _LOGGER.debug("%s registered for updates", self.entity_id) @callback def _async_receive_data(self, instrument: Any) -> None: """Receive new tracker data.""" if instrument.full_name != self._attr_unique_id: return _LOGGER.debug("Update received for %s", self.entity_id) self._instrument = instrument self._update_state_from_instrument() self.async_write_ha_state() ================================================ FILE: custom_components/audiconnect/lock.py ================================================ """Support for Audi Connect locks.""" import logging from homeassistant.components.lock import LockEntity from homeassistant.const import CONF_USERNAME from .audi_entity import AudiEntity from .const import DOMAIN _LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way.""" async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] account = config_entry.data.get(CONF_USERNAME) audiData = hass.data[DOMAIN][account] for config_vehicle in audiData.config_vehicles: for lock in config_vehicle.locks: sensors.append(AudiLock(config_vehicle, lock)) async_add_entities(sensors) class AudiLock(AudiEntity, LockEntity): """Represents a car lock.""" @property def is_locked(self): """Return true if lock is locked.""" return self._instrument.is_locked async def async_lock(self, **kwargs): """Lock the car.""" await self._instrument.lock() async def async_unlock(self, **kwargs): """Unlock the car.""" await self._instrument.unlock() ================================================ FILE: custom_components/audiconnect/manifest.json ================================================ { "domain": "audiconnect", "name": "Audi Connect", "codeowners": ["@audiconnect"], "config_flow": true, "documentation": "https://github.com/audiconnect/audi_connect_ha", "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/audiconnect/audi_connect_ha/issues", "loggers": ["audiconnect"], "requirements": ["beautifulsoup4"], "version": "1.19.1" } ================================================ FILE: custom_components/audiconnect/sensor.py ================================================ """Support for Audi Connect sensors.""" import logging from homeassistant.components.sensor import SensorEntity from homeassistant.const import CONF_USERNAME from .audi_entity import AudiEntity from .const import DOMAIN _LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up platform.""" async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Audi sensors from a config entry.""" sensors = [] account = config_entry.data.get(CONF_USERNAME) audiData = hass.data[DOMAIN][account] for config_vehicle in audiData.config_vehicles: for sensor in config_vehicle.sensors: sensors.append(AudiSensor(config_vehicle, sensor)) async_add_entities(sensors, True) class AudiSensor(AudiEntity, SensorEntity): """Representation of an Audi sensor.""" @property def native_value(self): """Return the native value.""" return self._instrument.state @property def native_unit_of_measurement(self): """Return the native unit of measurement.""" return self._instrument.unit @property def device_class(self): """Return the device_class.""" return self._instrument.device_class @property def state_class(self): """Return the state_class.""" return self._instrument.state_class @property def entity_category(self): """Return the entity_category.""" return self._instrument.entity_category @property def extra_state_attributes(self): """Return additional state attributes.""" return self._instrument.extra_state_attributes @property def suggested_display_precision(self): """Return the suggested number of decimal digits for display.""" return self._instrument.suggested_display_precision ================================================ FILE: custom_components/audiconnect/services.yaml ================================================ --- # Describes the format for available services for audiconnect refresh_vehicle_data: fields: vin: required: true example: WBANXXXXXX1234567 selector: text: execute_vehicle_action: fields: vin: required: true example: WBANXXXXXX1234567 selector: text: action: required: true example: "lock" selector: select: translation_key: vehicle_actions options: - lock - unlock - start_climatisation - stop_climatisation - start_charger - start_timed_charger - stop_charger - start_preheater - stop_preheater - start_window_heating - stop_window_heating start_climate_control: fields: vin: required: true example: WBANXXXXXX1234567 selector: text: temp_f: selector: number: min: 59 max: 85 temp_c: selector: number: min: 15 max: 30 glass_heating: selector: boolean: seat_fl: selector: boolean: seat_fr: selector: boolean: seat_rl: selector: boolean: seat_rr: selector: boolean: climatisation_at_unlock: selector: boolean: climatisation_mode: example: "comfort" selector: select: options: - comfort - economy start_auxiliary_heating: fields: vin: required: true example: WBANXXXXXX1234567 selector: text: duration: selector: number: min: 10 max: 60 step: 10 unit_of_measurement: "minutes" set_target_soc: fields: vin: required: true example: WBANXXXXXX1234567 selector: text: target_soc: required: true example: 80 selector: number: min: 20 max: 100 step: 5 unit_of_measurement: "%" ================================================ FILE: custom_components/audiconnect/strings.json ================================================ { "config": { "abort": { "invalid_credentials": "Invalid credentials", "user_already_configured": "Account has already been configured" }, "create_entry": {}, "error": { "invalid_credentials": "Invalid credentials", "invalid_username": "Invalid username", "unexpected": "Unexpected error communicating with Audi Connect server", "user_already_configured": "Account has already been configured" }, "step": { "user": { "data": { "password": "Password", "username": "Username", "spin": "S-PIN", "region": "Region", "scan_interval": "Scan interval", "api_level": "API Level" }, "title": "Audi Connect Account Info", "data_description": { "api_level": "For Audi vehicles, the API request data structure varies by model. Newer vehicles use an updated data structure compared to older models. Adjusting the API Level ensures that the system automatically applies the correct data structure for each specific vehicle. This can be updated from the CONFIGURE menu later, if needed." } } } }, "options": { "step": { "init": { "data": { "scan_initial": "Cloud Update at Startup", "scan_active": "Active Polling at Scan Interval", "scan_interval": "Scan Interval", "api_level": "API Level" }, "title": "Audi Connect Options", "data_description": { "scan_initial": "Perform a cloud update immediately upon startup.", "scan_active": "Perform a cloud update at the set scan interval.", "scan_interval": "Minutes between active polling. If 'Active Polling at Scan Interval' is off, this value will have no impact.", "api_level": "For Audi vehicles, the API request data structure varies by model. Newer vehicles use an updated data structure compared to older models. Adjusting the API Level ensures that the system automatically applies the correct data structure for each specific vehicle." } } } }, "selector": { "vehicle_actions": { "options": { "lock": "Lock", "unlock": "Unlock", "start_climatisation": "Start Climatisation (Legacy)", "stop_climatisation": "Stop Climatisation", "start_charger": "Start Charger", "start_timed_charger": "Start timed Charger", "stop_charger": "Stop Charger", "start_preheater": "Start Preheater (Legacy)", "stop_preheater": "Stop Preheater", "start_window_heating": "Start Window heating", "stop_window_heating": "Stop Windows heating" } } }, "services": { "refresh_vehicle_data": { "name": "Refresh Vehicle Data", "description": "Requests an update of the vehicle state directly, as opposed to the normal update mechanism which only retrieves data from the cloud.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." } } }, "execute_vehicle_action": { "name": "Execute Vehicle Action", "description": "Performs various actions on the vehicle.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." }, "action": { "name": "Action", "description": "The specific action to perform on the vehicle. Note that available actions may vary based on the vehicle.", "example": "lock" } } }, "start_climate_control": { "name": "Start Climate Control", "description": "Start the climate control with options for temperature, glass surface heating, and auto seat comfort.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." }, "temp_f": { "name": "Target Temperature (Fahrenheit)", "description": "(Optional) Set temperature in °F. Defaults to 70°F if not provided. Overrides 'temp_c'." }, "temp_c": { "name": "Target Temperature (Celsius)", "description": "(Optional) Set temperature in °C. Defaults to 21°C if not provided. Overridden if 'temp_f' is provided." }, "glass_heating": { "name": "Glass Surface Heating", "description": "(Optional) Enable or disable glass surface heating." }, "seat_fl": { "name": "Auto Seat Comfort: Front-Left", "description": "(Optional) Enable or disable Auto Seat Comfort for the front-left seat." }, "seat_fr": { "name": "Auto Seat Comfort: Front-Right", "description": "(Optional) Enable or disable Auto Seat Comfort for the front-right seat." }, "seat_rl": { "name": "Auto Seat Comfort: Rear-Left", "description": "(Optional) Enable or disable Auto Seat Comfort for the rear-left seat." }, "seat_rr": { "name": "Auto Seat Comfort: Rear-Right", "description": "(Optional) Enable or disable Auto Seat Comfort for the rear-right seat." }, "climatisation_at_unlock": { "name": "Climatisation at Unlock", "description": "(Optional) Enable climate control to continue when vehicle is unlocked." }, "climatisation_mode": { "name": "Climatisation Mode", "description": "(Optional) Maximum comfort (Comfort) or energy-saving (Economy). Default is Comfort." } } }, "refresh_cloud_data": { "name": "Refresh Cloud Data", "description": "Retrieves current cloud data without triggering a vehicle refresh. Data may be outdated if the vehicle has not checked in recently." }, "start_auxiliary_heating": { "name": "Start Auxiliary Heating", "description": "Start auxiliary heating the vehicle, with option for duration.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." }, "duration": { "name": "Duration", "description": "The number of minutes the auxiliary heater should run before turning off. Default is 20 minutes if not provided." } } }, "set_target_soc": { "name": "Set Target State of Charge", "description": "Set the target state of charge (battery %) for the vehicle.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." }, "target_soc": { "name": "Target State of Charge", "description": "Target state of charge percentage (20-100%)." } } } } } ================================================ FILE: custom_components/audiconnect/switch.py ================================================ """Support for Audi Connect switches""" import logging from homeassistant.helpers.entity import ToggleEntity from homeassistant.const import CONF_USERNAME from .audi_entity import AudiEntity from .const import DOMAIN _LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way.""" async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] account = config_entry.data.get(CONF_USERNAME) audiData = hass.data[DOMAIN][account] for config_vehicle in audiData.config_vehicles: for switch in config_vehicle.switches: sensors.append(AudiSwitch(config_vehicle, switch)) async_add_entities(sensors) class AudiSwitch(AudiEntity, ToggleEntity): """Representation of a Audi switch.""" @property def is_on(self): """Return true if switch is on.""" return self._instrument.state async def async_turn_on(self, **kwargs): """Turn the switch on.""" await self._instrument.turn_on() async def async_turn_off(self, **kwargs): """Turn the switch off.""" await self._instrument.turn_off() ================================================ FILE: custom_components/audiconnect/translations/de.json ================================================ { "config": { "abort": { "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen", "user_already_configured": "Konto wurde bereits konfiguriert" }, "create_entry": {}, "error": { "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen", "invalid_username": "Ung\u00fcltiger Benutzername", "unexpected": "Unerwarteter Fehler bei der Kommunikation mit dem Audi Connect Server", "user_already_configured": "Konto wurde bereits konfiguriert" }, "step": { "user": { "data": { "password": "Passwort", "username": "Benutzername", "spin": "S-PIN", "region": "Region", "scan_interval": "Abfrageintervall", "api_level": "API-Level" }, "title": "Audi Connect Kontoinformationen", "data_description": { "api_level": "Die Datenstruktur des API-Requests variiert je nach Audi-Modell. Neuere Fahrzeuge verwenden eine aktualisierte Struktur im Vergleich zu älteren Modellen. Durch die Anpassung des API-Levels wird sichergestellt, dass das Fahrzeug die korrekte, fahrzeugspezifische Datenstruktur nutzt. Diese Einstellung kann später unter „KONFIGURATION“ geändert werden." } } } }, "options": { "step": { "init": { "data": { "scan_initial": "Cloud-Update beim Start", "scan_active": "Aktive Abfrage im Scanintervall", "scan_interval": "Abfrageintervall", "api_level": "API-Level" }, "title": "Audi Connect-Optionen", "data_description": { "scan_initial": "Führen Sie sofort nach dem Start ein Cloud-Update durch.", "scan_active": "Führen Sie im festgelegten Scanintervall ein Cloud-Update durch.", "scan_interval": "Minuten zwischen aktiven Abfragen. Wenn „Aktive Abfrage im Scanintervall“ deaktiviert ist, hat dieser Wert keine Auswirkung.", "api_level": "Die Datenstruktur des API-Requests variiert je nach Audi-Modell. Neuere Fahrzeuge verwenden eine aktualisierte Struktur im Vergleich zu älteren Modellen. Durch die Anpassung des API-Levels wird sichergestellt, dass das Fahrzeug die korrekte, fahrzeugspezifische Datenstruktur nutzt. Diese Einstellung kann später unter „KONFIGURATION“ geändert werden." } } } }, "selector": { "vehicle_actions": { "options": { "lock": "Sperren", "unlock": "Freischalten", "start_climatisation": "Klimatisierung starten (Legacy)", "stop_climatisation": "Schluss mit der Klimatisierung", "start_charger": "Ladegerät starten", "start_timed_charger": "Starten Sie das zeitgesteuerte Ladegerät", "stop_charger": "Stoppen Sie das Ladegerät", "start_preheater": "Vorwärmer starten", "stop_preheater": "Stoppen Sie den Vorwärmer", "start_window_heating": "Fensterheizung starten", "stop_window_heating": "Stoppen Sie die Fensterheizung", "is_moving": "In Bewegung" } } }, "services": { "refresh_vehicle_data": { "name": "Fahrzeugdaten aktualisieren", "description": "Fordert direkt eine Aktualisierung des Fahrzeugstatus an, im Gegensatz zum normalen Aktualisierungsmechanismus, der nur Daten aus der Cloud abruft.", "fields": { "vin": { "name": "VIN", "description": "Die Fahrzeugidentifikationsnummer (VIN) des Audi-Fahrzeugs. Dies sollte eine 17-stellige Kennung sein, die für jedes Fahrzeug eindeutig ist." } } }, "execute_vehicle_action": { "name": "Fahrzeugaktionen ausfuhren", "description": "Führt verschiedene Aktionen am Fahrzeug aus.", "fields": { "vin": { "name": "VIN", "description": "Die Fahrzeugidentifikationsnummer (VIN) des Audi-Fahrzeugs. Dies sollte eine 17-stellige Kennung sein, die für jedes Fahrzeug eindeutig ist." }, "action": { "name": "Aktion", "description": "Die spezifische Aktion, die am Fahrzeug ausgeführt werden soll. Beachten Sie, dass die verfügbaren Aktionen je nach Fahrzeug variieren können.", "example": "lock" } } }, "start_climate_control": { "name": "Starten Sie die Klimatisierung", "description": "Starten Sie die Klimaanlage mit Optionen für Temperatur, Glasflächenheizung und automatischen Sitzkomfort.", "fields": { "vin": { "name": "VIN", "description": "Die Fahrzeugidentifikationsnummer (VIN) des Audi-Fahrzeugs. Dies sollte eine 17-stellige Kennung sein, die für jedes Fahrzeug eindeutig ist." }, "temp_f": { "name": "Zieltemperatur (Fahrenheit)", "description": "(Optional) Stellen Sie die Temperatur in °F ein. Standardmäßig 70 °F, sofern nicht angegeben. Überschreibt 'temp_c'." }, "temp_c": { "name": "Zieltemperatur (Celsius)", "description": "(Optional) Stellen Sie die Temperatur in °C ein. Standardmäßig 21 °C, sofern nicht angegeben. Wird überschrieben, wenn „temp_f“ bereitgestellt wird." }, "glass_heating": { "name": "Glasflächenheizung", "description": "(Optional) Aktivieren oder deaktivieren Sie die Glasflächenheizung." }, "seat_fl": { "name": "Automatischer Sitzkomfort: Vorne links", "description": "(Optional) Aktivieren oder deaktivieren Sie den automatischen Sitzkomfort für den vorderen linken Sitz." }, "seat_fr": { "name": "Automatischer Sitzkomfort: Vorne rechts", "description": "(Optional) Aktivieren oder deaktivieren Sie den automatischen Sitzkomfort für den Vordersitz rechts." }, "seat_rl": { "name": "Automatischer Sitzkomfort: Hinten links", "description": "(Optional) Aktivieren oder deaktivieren Sie den automatischen Sitzkomfort für den linken Rücksitz." }, "seat_rr": { "name": "Automatischer Sitzkomfort: Hinten rechts", "description": "(Optional) Aktivieren oder deaktivieren Sie den automatischen Sitzkomfort für den rechten Rücksitz." } } }, "refresh_cloud_data": { "name": "Cloud-Daten aktualisieren", "description": "Ruft aktuelle Cloud-Daten ab, ohne eine Fahrzeugaktualisierung auszulösen. Die Daten sind möglicherweise veraltet, wenn das Fahrzeug nicht kürzlich eingecheckt wurde." } } } ================================================ FILE: custom_components/audiconnect/translations/en.json ================================================ { "config": { "abort": { "invalid_credentials": "Invalid credentials", "user_already_configured": "Account has already been configured" }, "create_entry": {}, "error": { "invalid_credentials": "Invalid credentials", "invalid_username": "Invalid username", "unexpected": "Unexpected error communicating with Audi Connect server", "user_already_configured": "Account has already been configured" }, "step": { "user": { "data": { "password": "Password", "username": "Username", "spin": "S-PIN", "region": "Region", "scan_interval": "Scan interval", "api_level": "API Level" }, "title": "Audi Connect Account Info", "data_description": { "api_level": "For Audi vehicles, the API request data structure varies by model. Newer vehicles use an updated data structure compared to older models. Adjusting the API Level ensures that the system automatically applies the correct data structure for each specific vehicle. This can be updated from the CONFIGURE menu later, if needed." } } } }, "options": { "step": { "init": { "data": { "scan_initial": "Cloud Update at Startup", "scan_active": "Active Polling at Scan Interval", "scan_interval": "Scan Interval", "api_level": "API Level" }, "title": "Audi Connect Options", "data_description": { "scan_initial": "Perform a cloud update immediately upon startup.", "scan_active": "Perform a cloud update at the set scan interval.", "scan_interval": "Minutes between active polling. If 'Active Polling at Scan Interval' is off, this value will have no impact.", "api_level": "For Audi vehicles, the API request data structure varies by model. Newer vehicles use an updated data structure compared to older models. Adjusting the API Level ensures that the system automatically applies the correct data structure for each specific vehicle." } } } }, "selector": { "vehicle_actions": { "options": { "lock": "Lock", "unlock": "Unlock", "start_climatisation": "Start Climatisation (Legacy)", "stop_climatisation": "Stop Climatisation", "start_charger": "Start Charger", "start_timed_charger": "Start timed Charger", "stop_charger": "Stop Charger", "start_preheater": "Start Preheater (Legacy)", "stop_preheater": "Stop Preheater", "start_window_heating": "Start Window heating", "stop_window_heating": "Stop Windows heating" } } }, "services": { "refresh_vehicle_data": { "name": "Refresh Vehicle Data", "description": "Requests an update of the vehicle state directly, as opposed to the normal update mechanism which only retrieves data from the cloud.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." } } }, "execute_vehicle_action": { "name": "Execute Vehicle Action", "description": "Performs various actions on the vehicle.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." }, "action": { "name": "Action", "description": "The specific action to perform on the vehicle. Note that available actions may vary based on the vehicle.", "example": "lock" } } }, "start_climate_control": { "name": "Start Climate Control", "description": "Start the climate control with options for temperature, glass surface heating, and auto seat comfort.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." }, "temp_f": { "name": "Target Temperature (Fahrenheit)", "description": "(Optional) Set temperature in °F. Defaults to 70°F if not provided. Overrides 'temp_c'." }, "temp_c": { "name": "Target Temperature (Celsius)", "description": "(Optional) Set temperature in °C. Defaults to 21°C if not provided. Overridden if 'temp_f' is provided." }, "glass_heating": { "name": "Glass Surface Heating", "description": "(Optional) Enable or disable glass surface heating." }, "seat_fl": { "name": "Auto Seat Comfort: Front-Left", "description": "(Optional) Enable or disable Auto Seat Comfort for the front-left seat." }, "seat_fr": { "name": "Auto Seat Comfort: Front-Right", "description": "(Optional) Enable or disable Auto Seat Comfort for the front-right seat." }, "seat_rl": { "name": "Auto Seat Comfort: Rear-Left", "description": "(Optional) Enable or disable Auto Seat Comfort for the rear-left seat." }, "seat_rr": { "name": "Auto Seat Comfort: Rear-Right", "description": "(Optional) Enable or disable Auto Seat Comfort for the rear-right seat." }, "climatisation_at_unlock": { "name": "Climatisation at Unlock", "description": "(Optional) Enable climate control to continue when vehicle is unlocked." }, "climatisation_mode": { "name": "Climatisation Mode", "description": "(Optional) Maximum comfort (Comfort) or energy-saving (Economy). Default is Comfort." } } }, "refresh_cloud_data": { "name": "Refresh Cloud Data", "description": "Retrieves current cloud data without triggering a vehicle refresh. Data may be outdated if the vehicle has not checked in recently." }, "start_auxiliary_heating": { "name": "Start Auxiliary Heating", "description": "Start auxiliary heating the vehicle, with option for duration.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." }, "duration": { "name": "Duration", "description": "The number of minutes the auxiliary heater should run before turning off. Default is 20 minutes if not provided." } } }, "set_target_soc": { "name": "Set Target State of Charge", "description": "Set the target state of charge (battery %) for the vehicle.", "fields": { "vin": { "name": "VIN", "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." }, "target_soc": { "name": "Target State of Charge", "description": "Target state of charge percentage (20-100%)." } } } } } ================================================ FILE: custom_components/audiconnect/translations/fi.json ================================================ { "config": { "abort": { "invalid_credentials": "Virheelliset tunnistetiedot", "user_already_configured": "Tili on jo määritetty" }, "create_entry": {}, "error": { "invalid_credentials": "Virheelliset tunnistetiedot", "invalid_username": "Virheellinen käyttäjätunnus", "unexpected": "Odottamaton virhe yhdistettäessä Audi Connect -palveluun", "user_already_configured": "Tili on jo määritetty" }, "step": { "user": { "data": { "password": "Salasana", "username": "Käyttäjätunnus", "spin": "S-PIN", "region": "Alue", "scan_interval": "Skannausväli", "api_level": "API-taso" }, "title": "Audi Connect -tilin tiedot", "data_description": { "api_level": "Audi-ajoneuvoissa API-pyynnön tietorakenne vaihtelee mallin mukaan. Uudemmat ajoneuvot käyttävät päivitettyä tietorakennetta verrattuna vanhempiin malleihin. API-tason säätäminen varmistaa, että järjestelmä käyttää kussakin ajoneuvossa oikeaa tietorakennetta. Tätä voi myöhemmin muuttaa ASETUKSET-valikon kautta tarvittaessa." } } } }, "options": { "step": { "init": { "data": { "scan_initial": "Päivitä pilvestä käynnistyksessä", "scan_active": "Aktiivinen tarkistus aikavälein", "scan_interval": "Aikaväli", "api_level": "API-taso" }, "title": "Audi Connect -asetukset", "data_description": { "scan_initial": "Päivitä pilvestä heti käynnistyksen yhteydessä.", "scan_active": "Päivitä pilvestä aikavälein.", "scan_interval": "Minuutit aktiivisten tarkistusten välillä. Jos 'Aktiivinen tarkistus aikavälein' ei ole käytössä, tämä arvo ei vaikuta.", "api_level": "Audi-ajoneuvoissa API-pyynnön tietorakenne vaihtelee mallin mukaan. Uudemmat ajoneuvot käyttävät päivitettyä tietorakennetta verrattuna vanhempiin malleihin. API-tason säätäminen varmistaa, että järjestelmä käyttää kussakin ajoneuvossa oikeaa tietorakennetta." } } } }, "selector": { "vehicle_actions": { "options": { "lock": "Lukitse", "unlock": "Avaa lukitus", "start_climatisation": "Käynnistä ilmastointi (perinteinen)", "stop_climatisation": "Pysäytä ilmastointi", "start_charger": "Käynnistä laturi", "start_timed_charger": "Aloita ajastettu lataus", "stop_charger": "Pysäytä laturi", "start_preheater": "Käynnistä lisälämmitin (perinteinen)", "stop_preheater": "Pysäytä lisälämmitin", "start_window_heating": "Käynnistä ikkunalämmitys", "stop_window_heating": "Pysäytä ikkunalämmitys" } } }, "services": { "refresh_vehicle_data": { "name": "Päivitä ajoneuvon tiedot", "description": "Pyytää ajoneuvon tilan päivitystä suoraan, pilvestä päivitystä odottamatta - toisin kuin normaali päivitys, joka hakee vain pilvitietoja.", "fields": { "vin": { "name": "VIN", "description": "Ajoneuvon tunnistenumero (VIN). Sen tulee olla 17-merkkinen yksilöllinen tunnus jokaiselle ajoneuvolle." } } }, "execute_vehicle_action": { "name": "Suorita ajoneuvotoiminto", "description": "Suorittaa erilaisia toimintoja ajoneuvolle.", "fields": { "vin": { "name": "VIN", "description": "Ajoneuvon tunnistenumero (VIN). VIN on 17-merkkiä pitkä, yksilöllinen tunnus." }, "action": { "name": "Toiminto", "description": "Ajoneuvolle suoritettava toiminto. Huomaa, että saatavilla olevat toiminnot voivat vaihdella ajoneuvon mukaan.", "example": "lukitse" } } }, "start_climate_control": { "name": "Käynnistä ilmastointi", "description": "Käynnistä ilmastointi lämpötila-, ikkunalämmitys- ja automaattisen istuinmukavuuden asetuksilla.", "fields": { "vin": { "name": "VIN", "description": "Ajoneuvon tunnistenumero (VIN). VIN on 17-merkkiä pitkä, yksilöllinen tunnus." }, "temp_f": { "name": "Kohdelämpötila (Fahrenheit)", "description": "(Valinnainen) Näytä lämpötila Fahrenheit-asteina. Oletusarvo on 70 °F, jos arvoa ei anneta. Ohittaa 'temp_c'-asetuksen." }, "temp_c": { "name": "Kohdelämpötila (Celsius)", "description": "(Valinnainen) Näytä lämpötila Celsius-asteina. Oletusarvo on 21 °C, jos arvoa ei anneta. Ohitetaan, jos 'temp_f' on asetettu." }, "glass_heating": { "name": "Ikkunan lämmitys", "description": "(Valinnainen) Ota ikkunan lämmitys käyttöön tai pois käytöstä." }, "seat_fl": { "name": "Automaattinen istuimen mukavuustoiminto: vasen etuistuin", "description": "(Valinnainen) Ota vasemman etuistuimen automaattinen istuimen mukavuustoiminto käyttöön tai pois käytöstä." }, "seat_fr": { "name": "Automaattinen istuimen mukavuustoiminto: oikea etuistuin", "description": "(Valinnainen) Ota oikean etuistuimen automaattinen istuimen mukavuustoiminto käyttöön tai pois käytöstä." }, "seat_rl": { "name": "Automaattinen istuimen mukavuustoiminto: vasen takaistuin", "description": "(Valinnainen) Ota vasemman takaistuimen automaattinen istuimen mukavuustoiminto käyttöön tai pois käytöstä." }, "seat_rr": { "name": "Automaattinen istuimen mukavuustoiminto: oikea takaistuin", "description": "(Valinnainen) Ota oikean takaistuimen automaattinen istuimen mukavuustoiminto käyttöön tai pois käytöstä." } } }, "refresh_cloud_data": { "name": "Päivitä pilvitiedot", "description": "Hakee nykyiset pilvitiedot käynnistämättä ajoneuvon päivitystä. Tiedot voivat olla vanhentuneita, jos ajoneuvo ei ole äskettäin ollut yhteydessä." }, "start_auxiliary_heating": { "name": "Käynnistä lisälämmitys", "description": "Käynnistä ajoneuvon lisälämmitys, kestoasetuksen valinta.", "fields": { "vin": { "name": "VIN", "description": "Ajoneuvon tunnistenumero (VIN). VIN on 17-merkkiä pitkä, yksilöllinen tunnus." }, "duration": { "name": "Kesto", "description": "Lisälämmittimen käyntiaika minuutteina ennen sammuttamista. Oletusarvo on 20 minuuttia, jos arvoa ei anneta." } } } } } ================================================ FILE: custom_components/audiconnect/translations/fr.json ================================================ { "config": { "abort": { "invalid_credentials": "Informations d'identification invalides", "user_already_configured": "Le compte a déjà été configuré" }, "create_entry": {}, "error": { "invalid_credentials": "Informations d'identification invalides", "invalid_username": "Nom d'utilisateur invalide", "unexpected": "Erreur inattendue lors de la communication avec le serveur Audi Connect", "user_already_configured": "Le compte a déjà été configuré" }, "step": { "user": { "data": { "password": "Mot de passe", "username": "Nom d'utilisateur", "spin": "S-PIN", "region": "Région", "scan_interval": "Intervalle de scan" }, "title": "Informations sur le compte Audi Connect" } } }, "options": { "step": { "init": { "data": { "scan_initial": "Mise à jour du cloud au démarrage", "scan_active": "Intervalle de mises à jour actives", "scan_interval": "Intervalle de mises à jour" }, "title": "Options Audi Connect", "data_description": { "scan_initial": "Effectuer une mise à jour du cloud immédiatement après le démarrage.", "scan_active": "Effectuer une mise à jour du cloud à intervalle régulier défini.", "scan_interval": "Minutes entre les mises à jour actives. Si 'Intervalle de mises à jour actives' est désactivé, cette valeur n'aura aucun impact." } } } }, "selector": { "vehicle_actions": { "options": { "lock": "Verrouiller", "unlock": "Déverrouiller", "start_climatisation": "Démarrer climatisation (Hérité)", "stop_climatisation": "Arrêter climatisation", "start_charger": "Démarrer chargeur", "start_timed_charger": "Démarrage chronométré du chargeur", "stop_charger": "Arrêter Chargeur", "start_preheater": "Démarrer préchauffage", "stop_preheater": "Arrêter préchauffage", "start_window_heating": "Démarrer chauffage des fenêtres", "stop_window_heating": "Arrêter chauffage des fenêtres" } } }, "services": { "refresh_vehicle_data": { "name": "Actualiser les données du véhicule", "description": "Demande directement une mise à jour de l'état du véhicule, contrairement au mécanisme de mise à jour normal qui récupère uniquement les données du cloud.", "fields": { "vin": { "name": "VIN", "description": "Le Vehicle Identification Number (VIN) du véhicule Audi. Il doit s'agir d'un identifiant de 17 caractères unique à chaque véhicule." } } }, "execute_vehicle_action": { "name": "Exécuter l'action du véhicule", "description": "Effectue diverses actions sur le véhicule.", "fields": { "vin": { "name": "VIN", "description": "Le Vehicle Identification Number (VIN) du véhicule Audi. Il doit s'agir d'un identifiant de 17 caractères unique à chaque véhicule." }, "action": { "name": "Action", "description": "L'action spécifique à effectuer sur le véhicule. Noter que les actions disponibles peuvent varier en fonction du véhicule.", "example": "Verrouiller" } } }, "start_climate_control": { "name": "Démarrer la climatisation", "description": "Démarrez la climatisation avec des options de température, de chauffage des vitres et de confort des sièges auto.", "fields": { "vin": { "name": "VIN", "description": "Le Vehicle Identification Number (VIN) du véhicule Audi. Il doit s'agir d'un identifiant de 17 caractères unique à chaque véhicule." }, "temp_f": { "name": "Température cible (Fahrenheit)", "description": "(Optionel) Régler la température en °F. La valeur par défaut est 70°F si elle n'est pas fournie. Remplace 'temp_c'." }, "temp_c": { "name": "Température cible (Celsius)", "description": "(Facultatif) Régler la température en °C. La valeur par défaut est 21°C si elle n'est pas fournie. Remplacé si 'temp_f' est fourni." }, "glass_heating": { "name": "Chauffage des vitres", "description": "(Facultatif) Activer ou désactiver le chauffage des surfaces vitrées." }, "seat_fl": { "name": "Confort du siège auto: Avant-Gauche", "description": "(Facultatif) Activer ou désactiver Confort du siège auto pour le siège avant gauche." }, "seat_fr": { "name": "Confort du siège auto: Avant-Droit", "description": "(Facultatif) Activer ou désactiver Confort du siège auto pour le siège avant droit." }, "seat_rl": { "name": "Confort du siège auto: Arrière-Gauche", "description": "(Facultatif) Activer ou désactiver Confort du siège auto pour le siège arrière gauche." }, "seat_rr": { "name": "Confort du siège auto: Arrière-Droit", "description": "(Facultatif) Activer ou désactiver Confort du siège auto pour le siège arrière droit." } } }, "refresh_cloud_data": { "name": "Actualiser les données cloud", "description": "Récupère les données cloud actuelles sans déclencher une actualisation du véhicule. Les données peuvent être obsolètes si le véhicule n'a pas été vérifié récemment." } } } ================================================ FILE: custom_components/audiconnect/translations/nb.json ================================================ { "config": { "abort": { "invalid_credentials": "Ugyldige innloggingsopplysninger", "user_already_configured": "Kontoen er allerede konfigurert" }, "create_entry": {}, "error": { "invalid_credentials": "Ugyldige innloggingsopplysninger", "invalid_username": "Ugyldig brukernavn", "unexpected": "Uventet feil i kommunikasjonen med Audi Connect-serveren", "user_already_configured": "Kontoen er allerede konfigurert" }, "step": { "user": { "data": { "password": "Passord", "username": "Brukernavn", "spin": "S-PIN", "region": "Region", "scan_interval": "Skanneintervall" }, "title": "Audi Connect kontoinformasjon" } } }, "options": { "step": { "init": { "data": { "scan_interval": "Skanneintervall" }, "title": "Audi Connect-alternativer", "data_description": { "scan_interval": "(Minutter) Omstart kreves for at nytt skanneintervall skal tre i kraft." } } } } } ================================================ FILE: custom_components/audiconnect/translations/nl.json ================================================ { "config": { "abort": { "invalid_credentials": "Ongeldige gebruikersgegevens", "user_already_configured": "Account is al geconfigureerd" }, "create_entry": {}, "error": { "invalid_credentials": "Ongeldige gebruikersgegevens", "invalid_username": "Ongeldige gebruikersnaam", "unexpected": "Onverwachte fout bij communicatie met de Audi Connect server", "user_already_configured": "Account is al geconfigureerd" }, "step": { "user": { "data": { "password": "Wachtwoord", "username": "Gebruikersnaam", "spin": "S-PIN", "region": "Regio", "scan_interval": "Update interval" }, "title": "Audi Connect accountgegevens" } } }, "options": { "step": { "init": { "data": { "scan_interval": "Scaninterval" }, "title": "Audi Connect-opties", "data_description": { "scan_interval": "(Minuten) Opnieuw opstarten vereist om het nieuwe scaninterval van kracht te laten worden." } } } } } ================================================ FILE: custom_components/audiconnect/translations/pt-BR.json ================================================ { "config": { "abort": { "invalid_credentials": "Credenciais inválidas", "user_already_configured": "A conta já foi configurada" }, "create_entry": {}, "error": { "invalid_credentials": "Credenciais inválidas", "invalid_username": "Nome de usuário inválido", "unexpected": "Erro inesperado na comunicação com o servidor Audi Connect", "user_already_configured": "A conta já foi configurada" }, "step": { "user": { "data": { "password": "Senha", "username": "Nome de usuário", "spin": "S-PIN", "region": "Região", "scan_interval": "Intervalo de escaneamento" }, "title": "Informações da conta Audi Connect " } } }, "options": { "step": { "init": { "data": { "scan_interval": "Intervalo de escaneamento" }, "title": "Opções Audi Connect", "data_description": { "scan_interval": "(Minutos) É necessário reiniciar para que o novo intervalo de verificação entre em vigor." } } } } } ================================================ FILE: custom_components/audiconnect/translations/pt.json ================================================ { "config": { "abort": { "invalid_credentials": "Credenciais inválidas", "user_already_configured": "A conta já foi configurada" }, "create_entry": {}, "error": { "invalid_credentials": "Credenciais inválidas", "invalid_username": "Nome de utilizador inválido", "unexpected": "Erro inesperado na comunicação com o servidor Audi Connect", "user_already_configured": "A conta já foi configurada" }, "step": { "user": { "data": { "password": "Senha", "username": "Nome de utilizador", "spin": "S-PIN", "region": "Região", "scan_interval": "Intervalo de pesquisa" }, "title": "Informações da conta Audi Connect " } } }, "options": { "step": { "init": { "data": { "scan_interval": "Intervalo de pesquisa" }, "title": "Opções Audi Connect", "data_description": { "scan_interval": "(Minutos) É necessário reiniciar para que o novo intervalo de verificação entre em vigor." } } } } } ================================================ FILE: custom_components/audiconnect/util.py ================================================ from functools import reduce from datetime import datetime, timezone import logging _LOGGER = logging.getLogger(__name__) def get_attr(dictionary, keys, default=None): return reduce( lambda d, key: d.get(key, default) if isinstance(d, dict) else default, keys.split("."), dictionary, ) def to_byte_array(hexString: str): result = [] for i in range(0, len(hexString), 2): result.append(int(hexString[i : i + 2], 16)) return result def log_exception(exception, message): err = message + ": " + str(exception).rstrip("\n") _LOGGER.error(err) def parse_int(val: str): try: return int(val) except (ValueError, TypeError): return None def parse_float(val: str): try: return float(val) except (ValueError, TypeError): return None def parse_datetime(time_value): """Converts timestamp to datetime object if it's a string, or returns it directly if already datetime.""" if isinstance(time_value, datetime): return time_value # Return the datetime object directly if already datetime elif isinstance(time_value, str): formats = [ "%Y-%m-%d %H:%M:%S%z", # Format: 2024-04-12 05:56:17+00:00 "%Y-%m-%dT%H:%M:%S.%fZ", # Format: 2024-04-12T05:56:13.025Z ] for fmt in formats: try: return datetime.strptime(time_value, fmt).replace(tzinfo=timezone.utc) except ValueError: continue return None ================================================ FILE: custom_components/test.py ================================================ import sys import asyncio import getopt from audiconnect.audi_connect_account import AudiConnectAccount from audiconnect.dashboard import Dashboard from aiohttp import ClientSession def printHelp(): print( "test.py --user --password --spin --country " ) async def main(argv): user = "" password = "" spin = "" country = "" try: opts, _ = getopt.getopt( argv, "hu:p:s:r:", ["user=", "password=", "spin=", "country="] ) except getopt.GetoptError: printHelp() sys.exit(2) for opt, arg in opts: if opt == "-h": printHelp() sys.exit() elif opt in ("-u", "--user"): user = arg elif opt in ("-p", "--password"): password = arg elif opt in ("-s", "--spin"): spin = arg elif opt in ("-r", "--country"): country = arg if user == "" or password == "": printHelp() sys.exit() async with ClientSession() as session: account = AudiConnectAccount(session, user, password, country, spin) await account.update(None) for vehicle in account._vehicles: dashboard = Dashboard(account, vehicle, miles=True) for instrument in dashboard.instruments: print(str(instrument), instrument.str_state) if __name__ == "__main__": task = main(sys.argv[1:]) res = asyncio.get_event_loop().run_until_complete(task) ================================================ FILE: hacs.json ================================================ { "name": "Audi connect", "homeassistant": "0.110.0" } ================================================ FILE: info.md ================================================ [![hacs][hacsbadge]](hacs) ![Project Maintenance][maintenance-shield] ## Configuration Configuration is done through the Home Assistant UI. To add the integration, go to **Settings ➤ Devices & Services ➤ Integrations**, click **➕ Add Integration**, and search for "Audi Connect". ![image](https://github.com/user-attachments/assets/68f4a38b-f09d-4486-a1a1-ab8a564095ab) ### Configuration Variables **username** - (string)(Required) The username associated with your Audi Connect account. **password** - (string)(Required) The password for your Audi Connect account. **S-PIN** - (string)(Optional) The S-PIN for your Audi Connect account. **region** - (Required) The region where your Audi Connect account is registered. - 'DE' for Europe (or leave unset) - 'US' for United States of America - 'CA' for Canada - 'CN' for China **scan_interval** - (number)(Optional) The frequency in minutes for how often to fetch status data from Audi Connect. (Optional. Default is 15 minutes, can be no more frequent than 15 min.) **api_level** - (number)(Required) For Audi vehicles, the API request data structure varies by model. Newer models use an updated structure, while older models use a legacy format. Setting the API level ensures that the system automatically applies the correct structure for each vehicle. You can update this setting later from the CONFIGURE menu if needed. - Level `0`: _Typically_ for gas vehicles - Level `1`: _Typically_ for e-tron (electric) vehicles. [commits-shield]: https://img.shields.io/github/commit-activity/y/audiconnect/audi_connect_ha?style=for-the-badge [commits]: https://github.com/audiconnect/audi_connect_ha/commits/master [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge [license-shield]: https://img.shields.io/github/license/arjenvrh/audi_connect_ha?style=for-the-badge [maintenance-shield]: https://img.shields.io/badge/maintainer-audiconnect-blue.svg?style=for-the-badge [blackbadge]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge [black]: https://github.com/ambv/black ================================================ FILE: readme.md ================================================ # Audi Connect Integration for Home Assistant [![GitHub Activity][commits-shield]][commits] [![License][license-shield]](LICENSE.md) [![Code Style][blackbadge]][black] [![hacs][hacsbadge]](hacs) ## Notices Due to API changes, **currently not all functionality is available**. Please open a issue to report the topics you are missing. ⚠️ Warning: Excessive use of certain features in this integration may result in temporary or permanent suspension of your Audi Connect account. Please use responsibly — abuse or misuse could potentially impact access for the entire community. Use at your own risk. ## Maintainers Wanted - Looking for maintainers for Translation documents. - Always looking for more help from the community. If you can resolve an issue, please submit a PR or reach out to the maintainers with the working code. ## Description The `audiconnect` component provides an integration with the Audi Connect cloud service. It adds presence detection, sensors such as range, mileage, and fuel level, and provides car actions such as locking/unlocking and setting the pre-heater. **Note:** Certain functions require special permissions from Audi, such as position update via GPS. Credit for initial API discovery go to the guys at the ioBroker VW-Connect forum, who were able to figure out how the API and the PIN hashing works. Also some implementation credit to davidgiga1993 of the original [AudiAPI](https://github.com/davidgiga1993/AudiAPI) Python package, on which some of this code is loosely based. Full credit for this integration goes to @arjenvrh for their outstanding work in putting it all together. We wouldn’t have this without their contributions. ## Installation There are two ways this integration can be installed into [Home Assistant](https://www.home-assistant.io). The easiest and recommended way is to install the integration using [HACS](https://hacs.xyz), which makes future updates easy to track and install. Alternatively, installation can be done manually by copying the files in this repository into the `custom_components` directory in the Home Assistant configuration directory: 1. Open the configuration directory of your Home Assistant installation. 2. If you do not have a `custom_components` directory, create it. 3. In the `custom_components` directory, create a new directory called `audiconnect`. 4. Copy all files from the `custom_components/audiconnect/` directory in this repository into the `audiconnect` directory. 5. Restart Home Assistant. 6. Add the integration to Home Assistant (see **Configuration**). ## Configuration Configuration is done through the Home Assistant UI. To add the integration, go to **Settings ➤ Devices & Services ➤ Integrations**, click **➕ Add Integration**, and search for "Audi Connect". ### Configuration Variables | Name | Type | Default | Description | | --------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Username` | `string` | – | The username associated with your Audi Connect account. | | `Password` | `string` | – | The password for your Audi Connect account. | | `S-PIN` | `string` | – | The S-PIN for your Audi Connect account to perform certain service actions.
(**Optional**) | | `Region` | `string` | `DE` | The region where your Audi Connect account is registered:
• `DE` – Europe
• `US` – United States
• `CA` – Canada
• `CN` – China | | `Scan Interval` | `int` | `15` | Frequency (in minutes) to fetch status data from Audi Connect.
Minimum allowed is 15 minutes.
\* _Can be updated later via the CONFIGURE menu._ | | `API Level` | `int` | `0` | Determines the API structure used for service action calls:
• `0` – _Typically_ Gas vehicles (legacy format)
• `1` – _Typically_ e-tron (electric vehicles, newer format)
\* _Can be updated later via the CONFIGURE menu._ | ## Options Find configuration options under **Settings ➤ Devices & Services ➤ Integrations ➤ Audi Connect ➤ Configure**: | Name | Type | Description | | --------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Cloud Update at Startup` | `bool` | Toggle the initial cloud update when the integration starts. Useful for development or frequent Home Assistant restarts. | | `Active Polling at Scan Interval` | `bool` | Enable or disable active polling. | | `Scan Interval` | `int` | Defines polling frequency in minutes (minimum 15). Only effective if **Active Polling** is enabled. | | `API Level` | `int` | Determines the API structure used for service action calls:
• `0` – _Typically_ Gas vehicles (legacy format)
• `1` – _Typically_ e-tron (electric vehicles, newer format) | _Note: The integration will reload automatically upon clicking `Submit`, but a Home Assistant restart is suggested._ ## Service Actions ### Audi Connect: Refresh Vehicle Data `audiconnect.refresh_vehicle_data` Normal updates retrieve data from the Audi Connect cloud service, and don't interact directly with the vehicle. _This_ service action triggers an update request from the vehicle itself. When data is retrieved successfully, Home Assistant is automatically updated. The service action requires a vehicle identification number (VIN) as a parameter. #### Parameters - **`vin`**: The Vehicle Identification Number (VIN) of the Audi you want to control. ### Audi Connect: Refresh Cloud Data `audiconnect.refresh_cloud_data` _This_ service action triggers an update request from the cloud. - Functionality: Updates data for all vehicles from the online source, mirroring the action performed at integration startup or during scheduled refresh intervals. - Behavior: Does not force a vehicle-side data refresh. Consequently, if vehicles haven't recently pushed updates, retrieved data might be outdated. - Note: This service action replicates the function of active polling without scheduling, offering a more granular control over data refresh moments. - **IMPORTANT:** This service action has no built in usage limits. Excessive use may result in a temporary suspension of your account. #### Parameters - `none` ### Audi Connect: Execute Vehicle Action `audiconnect.execute_vehicle_action` This service action allows you to perform actions on your Audi vehicle, specified by the vehicle identification number (VIN) and the desired action. #### Service Parameters - **`vin`**: The Vehicle Identification Number (VIN) of the Audi you want to control. - **`action`**: The specific action to perform on the vehicle. Available actions include: - **`lock`**: Lock the vehicle. - **`unlock`**: Unlock the vehicle. - **`start_climatisation`**: Start the vehicle's climatisation system. (Legacy) -- Deprecated - **`stop_climatisation`**: Stop the vehicle's climatisation system. - **`start_charger`**: Start charging the vehicle. - **`start_timed_charger`**: Start the vehicle's charger with a timer. - **`stop_charger`**: Stop charging the vehicle. - **`start_preheater`**: Start the vehicle's preheater system. (Legacy) -- Deprecated - **`stop_preheater`**: Stop the vehicle's preheater system. - **`start_window_heating`**: Start heating the vehicle's windows. - **`stop_window_heating`**: Stop heating the vehicle's windows. #### Usage Example To initiate the lock action for a vehicle with VIN `WAUZZZ4G7EN123456`, use the following service call: ```yaml service: audiconnect.execute_vehicle_action data: vin: "WAUZZZ4G7EN123456" action: "lock" ``` #### Notes - Certain service actions require the S-PIN to be set in the configuration. - When the service action is successfully performed, an update request is automatically triggered. ### Audi Connect: Start Climate Control `audiconnect.start_climate_control` This service action allows you to start the climate control with options for temperature, glass surface heating, and auto seat comfort. #### Parameters - **`vin`**: The Vehicle Identification Number (VIN) of the Audi you want to control. - **`temp_f`** (_optional_): Desired temperature in Fahrenheit. Default is `70`. - **`temp_c`** (_optional_): Desired temperature in Celsius. Default is `21`. - **`glass_heating`** (_optional_): Enable (`True`) or disable (`False`) glass heating. Default is `False`. - **`seat_fl`** (_optional_): Enable (`True`) or disable (`False`) the front-left seat heater. Default is `False`. - **`seat_fr`** (_optional_): Enable (`True`) or disable (`False`) the front-right seat heater. Default is `False`. - **`seat_rl`** (_optional_): Enable (`True`) or disable (`False`) the rear-left seat heater. Default is `False`. - **`seat_rr`** (_optional_): Enable (`True`) or disable (`False`) the rear-right seat heater. Default is `False`. #### Usage Example To start the climate control for a vehicle with VIN `WAUZZZ4G7EN123456` with a temperature of 72°F, enable glass heating, and activate both front seat heaters, use the following service call: ```yaml service: audiconnect.start_climate_control data: vin: "WAUZZZ4G7EN123456" temp_f: 72 glass_heating: True seat_fl: True seat_fr: True ``` #### Notes - The `temp_f` and `temp_c` parameters are mutually exclusive. If both are provided, `temp_f` takes precedence. - If neither `temp_f` nor `temp_c` is provided, the system defaults to 70°F or 21°C. - When the service action is successfully performed, an update request is automatically triggered. ### Audi Connect: Start Auxiliary Heating `audiconnect.start_auxiliary_heating` This service action allows you to start auxiliary heating the vehicle, with option for duration. #### Parameters - **`vin`**: The Vehicle Identification Number (VIN) of the Audi you want to control. - **`duration`** (_optional_): The number of minutes the auxiliary heater should run before turning off. Default is `20` minutes if not provided. #### Usage Example To start the auxiliary heater for a vehicle with VIN `WAUZZZ4G7EN123456`, and a duration of 40 minutes, use the following service call action: ```yaml service: audiconnect.start_auxiliary_heating data: vin: "WAUZZZ4G7EN123456" duration: 40 ``` #### Notes - Requires the S-PIN to be set in the configuration. - When the service action is successfully performed, an update request is automatically triggered. ## Example Dashboard Card Below is an example Dashboard (Lovelace) card illustrating some of the sensors this Home Assistant addon provides. ![Example Dashboard Card](card_example.png) The card requires the following front end mods: - https://github.com/thomasloven/lovelace-card-mod - https://github.com/custom-cards/circle-sensor-card These mods can (like this integration) be installed using HACS. The card uses the following code in `ui-lovelace.yaml` (or wherever your Dashboard is configured). ```yaml - type: picture-elements image: /local/pictures/audi_sq7.jpeg style: | ha-card { border-radius: 10px; border: solid 1px rgba(100,100,100,0.3); box-shadow: 3px 3px rgba(0,0,0,0.4); overflow: hidden; } elements: - type: image image: /local/pictures/cardbackK.png style: left: 50% top: 90% width: 100% height: 60px - type: icon icon: mdi:car-door entity: sensor.doors_trunk_sq7 tap_action: more_info style: {color: white, left: 10%, top: 86%} - type: state-label entity: sensor.doors_trunk_sq7 style: {color: white, left: 10%, top: 95%} - type: state-icon entity: sensor.windows_sq7 tap_action: more_info style: {color: white, left: 30%, top: 86%} - type: state-label entity: sensor.windows_sq7 style: {color: white, left: 30%, top: 95%} - type: icon icon: mdi:oil entity: sensor.audi_sq7_oil_level tap_action: more_info style: {color: white, left: 50%, top: 86%} - type: state-label entity: sensor.audi_sq7_oil_level style: {color: white, left: 50%, top: 95%} - type: icon icon: mdi:room-service-outline entity: sensor.audi_sq7_service_inspection_time tap_action: more_info style: {color: white, left: 70%, top: 86%} - type: state-label entity: sensor.audi_sq7_service_inspection_time style: {color: white, left: 70%, top: 95%} - type: icon icon: mdi:speedometer entity: sensor.audi_sq7_mileage tap_action: more_info style: {color: white, left: 90%, top: 86%} - type: state-label entity: sensor.audi_sq7_mileage style: {color: white, left: 90%, top: 95%} - type: custom:circle-sensor-card entity: sensor.audi_sq7_tank_level max: 100 min: 0 stroke_width: 15 gradient: true fill: '#aaaaaabb' name: tank units: ' ' font_style: font-size: 1.0em font-color: white text-shadow: '1px 1px black' style: top: 5% left: 80% width: 4em height: 4em transform: none - type: custom:circle-sensor-card entity: sensor.audi_sq7_range max: 630 min: 0 stroke_width: 15 gradient: true fill: '#aaaaaabb' name: range units: ' ' font_style: font-size: 1.0em font-color: white text-shadow: '1px 1px black' style: top: 5% left: 5% width: 4em height: 4em transform: none ``` [commits-shield]: https://img.shields.io/github/commit-activity/y/audiconnect/audi_connect_ha?style=for-the-badge [commits]: https://github.com/audiconnect/audi_connect_ha/commits/master [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge [license-shield]: https://img.shields.io/github/license/audiconnect/audi_connect_ha?style=for-the-badge [maintenance-shield]: https://img.shields.io/badge/maintainer-audiconnect-blue.svg?style=for-the-badge [blackbadge]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge [black]: https://github.com/ambv/black