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".

### 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.

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