├── ha_config.png ├── hacs.json ├── card_example.png ├── .github ├── dependabot.yml ├── workflows │ ├── semanticTitle.yaml │ ├── validate.yml │ ├── inactiveIssues.yml │ └── release.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── custom_components ├── audiconnect │ ├── manifest.json │ ├── translations │ │ ├── nb.json │ │ ├── nl.json │ │ ├── pt.json │ │ ├── pt-BR.json │ │ ├── fr.json │ │ ├── en.json │ │ └── de.json │ ├── lock.py │ ├── switch.py │ ├── binary_sensor.py │ ├── util.py │ ├── services.yaml │ ├── sensor.py │ ├── const.py │ ├── audi_entity.py │ ├── audi_api.py │ ├── __init__.py │ ├── strings.json │ ├── config_flow.py │ ├── device_tracker.py │ ├── audi_account.py │ ├── climate.py │ ├── audi_models.py │ └── dashboard.py └── test.py ├── LICENSE ├── CONTRIBUTING.md ├── .gitignore ├── info.md ├── .pre-commit-config.yaml └── readme.md /ha_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/audi_connect_ha/master/ha_config.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Audi connect", 3 | "homeassistant": "0.110.0" 4 | } 5 | -------------------------------------------------------------------------------- /card_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/g3gg0/audi_connect_ha/master/card_example.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for Python 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | # Check for updates once a week 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /custom_components/audiconnect/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "audiconnect", 3 | "name": "Audi Connect", 4 | "codeowners": ["@arjenvrh"], 5 | "config_flow": true, 6 | "documentation": "https://github.com/audiconnect/audi_connect_ha", 7 | "integration_type": "hub", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/audiconnect/audi_connect_ha/issues", 10 | "loggers": ["audiconnect"], 11 | "requirements": ["beautifulsoup4"], 12 | "version": "1.12.2" 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/semanticTitle.yaml: -------------------------------------------------------------------------------- 1 | name: "Semantic Title" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Please look up the latest version from 15 | # https://github.com/amannn/action-semantic-pull-request/releases 16 | - uses: amannn/action-semantic-pull-request@v5.5.3 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hassfest: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Hassfest validation 16 | uses: home-assistant/actions/hassfest@master 17 | 18 | validate-hacs: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: HACS validation 23 | uses: hacs/action@main 24 | with: 25 | category: integration 26 | -------------------------------------------------------------------------------- /.github/workflows/inactiveIssues.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | actions: write 12 | pull-requests: write 13 | steps: 14 | - uses: actions/stale@v9 15 | with: 16 | days-before-issue-stale: 45 17 | days-before-issue-close: 15 18 | stale-issue-label: "stale" 19 | stale-issue-message: "This issue is stale because it has been open for 45 days with no activity. Are you still experiencing this issue? " 20 | close-issue-message: "This issue was closed because it has been inactive for 15 days since being marked as stale." 21 | days-before-pr-stale: -1 22 | days-before-pr-close: -1 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Logfile** 27 | How to enable audiconnect debugging? 28 | Settings > Integrations > Audi Connect > Enable Debug Logging 29 | Run service refresh_cloud_data 30 | Disable Debug Logging 31 | 32 | **Your Vehicle Details** 33 | Model: 34 | Year: 35 | Type (Gas/Hybrid/Electric): 36 | Region (EU/US/CA/CN): 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | 21 | **Logfile** 22 | How to enable audiconnect debugging? 23 | Settings > Integrations > Audi Connect > Enable Debug Logging 24 | Run service refresh_cloud_data 25 | Disable Debug Logging 26 | 27 | **Your Vehicle Details** 28 | Model: 29 | Year: 30 | Type (ICE/PHEV/BEV): 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Arjen van Rhijn @arjenvrh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/audiconnect/translations/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "invalid_credentials": "Ugyldige innloggingsopplysninger", 5 | "user_already_configured": "Kontoen er allerede konfigurert" 6 | }, 7 | "create_entry": {}, 8 | "error": { 9 | "invalid_credentials": "Ugyldige innloggingsopplysninger", 10 | "invalid_username": "Ugyldig brukernavn", 11 | "unexpected": "Uventet feil i kommunikasjonen med Audi Connect-serveren", 12 | "user_already_configured": "Kontoen er allerede konfigurert" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "password": "Passord", 18 | "username": "Brukernavn", 19 | "spin": "S-PIN", 20 | "region": "Region", 21 | "scan_interval": "Skanneintervall" 22 | }, 23 | "title": "Audi Connect kontoinformasjon" 24 | } 25 | } 26 | }, 27 | "options": { 28 | "step": { 29 | "init": { 30 | "data": { 31 | "scan_interval": "Skanneintervall" 32 | }, 33 | "title": "Audi Connect-alternativer", 34 | "data_description": { 35 | "scan_interval": "(Minutter) Omstart kreves for at nytt skanneintervall skal tre i kraft." 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /custom_components/audiconnect/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "invalid_credentials": "Ongeldige gebruikersgegevens", 5 | "user_already_configured": "Account is al geconfigureerd" 6 | }, 7 | "create_entry": {}, 8 | "error": { 9 | "invalid_credentials": "Ongeldige gebruikersgegevens", 10 | "invalid_username": "Ongeldige gebruikersnaam", 11 | "unexpected": "Onverwachte fout bij communicatie met de Audi Connect server", 12 | "user_already_configured": "Account is al geconfigureerd" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "password": "Wachtwoord", 18 | "username": "Gebruikersnaam", 19 | "spin": "S-PIN", 20 | "region": "Regio", 21 | "scan_interval": "Update interval" 22 | }, 23 | "title": "Audi Connect accountgegevens" 24 | } 25 | } 26 | }, 27 | "options": { 28 | "step": { 29 | "init": { 30 | "data": { 31 | "scan_interval": "Scaninterval" 32 | }, 33 | "title": "Audi Connect-opties", 34 | "data_description": { 35 | "scan_interval": "(Minuten) Opnieuw opstarten vereist om het nieuwe scaninterval van kracht te laten worden." 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /custom_components/audiconnect/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "invalid_credentials": "Credenciais inválidas", 5 | "user_already_configured": "A conta já foi configurada" 6 | }, 7 | "create_entry": {}, 8 | "error": { 9 | "invalid_credentials": "Credenciais inválidas", 10 | "invalid_username": "Nome de utilizador inválido", 11 | "unexpected": "Erro inesperado na comunicação com o servidor Audi Connect", 12 | "user_already_configured": "A conta já foi configurada" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "password": "Senha", 18 | "username": "Nome de utilizador", 19 | "spin": "S-PIN", 20 | "region": "Região", 21 | "scan_interval": "Intervalo de pesquisa" 22 | }, 23 | "title": "Informações da conta Audi Connect " 24 | } 25 | } 26 | }, 27 | "options": { 28 | "step": { 29 | "init": { 30 | "data": { 31 | "scan_interval": "Intervalo de pesquisa" 32 | }, 33 | "title": "Opções Audi Connect", 34 | "data_description": { 35 | "scan_interval": "(Minutos) É necessário reiniciar para que o novo intervalo de verificação entre em vigor." 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /custom_components/audiconnect/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "invalid_credentials": "Credenciais inválidas", 5 | "user_already_configured": "A conta já foi configurada" 6 | }, 7 | "create_entry": {}, 8 | "error": { 9 | "invalid_credentials": "Credenciais inválidas", 10 | "invalid_username": "Nome de usuário inválido", 11 | "unexpected": "Erro inesperado na comunicação com o servidor Audi Connect", 12 | "user_already_configured": "A conta já foi configurada" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "password": "Senha", 18 | "username": "Nome de usuário", 19 | "spin": "S-PIN", 20 | "region": "Região", 21 | "scan_interval": "Intervalo de escaneamento" 22 | }, 23 | "title": "Informações da conta Audi Connect " 24 | } 25 | } 26 | }, 27 | "options": { 28 | "step": { 29 | "init": { 30 | "data": { 31 | "scan_interval": "Intervalo de escaneamento" 32 | }, 33 | "title": "Opções Audi Connect", 34 | "data_description": { 35 | "scan_interval": "(Minutos) É necessário reiniciar para que o novo intervalo de verificação entre em vigor." 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /custom_components/audiconnect/lock.py: -------------------------------------------------------------------------------- 1 | """Support for Audi Connect locks.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.lock import LockEntity 6 | from homeassistant.const import CONF_USERNAME 7 | 8 | from .audi_entity import AudiEntity 9 | from .const import DOMAIN 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 15 | """Old way.""" 16 | 17 | 18 | async def async_setup_entry(hass, config_entry, async_add_entities): 19 | sensors = [] 20 | account = config_entry.data.get(CONF_USERNAME) 21 | audiData = hass.data[DOMAIN][account] 22 | 23 | for config_vehicle in audiData.config_vehicles: 24 | for lock in config_vehicle.locks: 25 | sensors.append(AudiLock(config_vehicle, lock)) 26 | 27 | async_add_entities(sensors) 28 | 29 | 30 | class AudiLock(AudiEntity, LockEntity): 31 | """Represents a car lock.""" 32 | 33 | @property 34 | def is_locked(self): 35 | """Return true if lock is locked.""" 36 | return self._instrument.is_locked 37 | 38 | async def async_lock(self, **kwargs): 39 | """Lock the car.""" 40 | await self._instrument.lock() 41 | 42 | async def async_unlock(self, **kwargs): 43 | """Unlock the car.""" 44 | await self._instrument.unlock() 45 | -------------------------------------------------------------------------------- /custom_components/audiconnect/switch.py: -------------------------------------------------------------------------------- 1 | """Support for Audi Connect switches""" 2 | 3 | import logging 4 | 5 | from homeassistant.helpers.entity import ToggleEntity 6 | from homeassistant.const import CONF_USERNAME 7 | 8 | from .audi_entity import AudiEntity 9 | from .const import DOMAIN 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 15 | """Old way.""" 16 | 17 | 18 | async def async_setup_entry(hass, config_entry, async_add_entities): 19 | sensors = [] 20 | account = config_entry.data.get(CONF_USERNAME) 21 | audiData = hass.data[DOMAIN][account] 22 | 23 | for config_vehicle in audiData.config_vehicles: 24 | for switch in config_vehicle.switches: 25 | sensors.append(AudiSwitch(config_vehicle, switch)) 26 | 27 | async_add_entities(sensors) 28 | 29 | 30 | class AudiSwitch(AudiEntity, ToggleEntity): 31 | """Representation of a Audi switch.""" 32 | 33 | @property 34 | def is_on(self): 35 | """Return true if switch is on.""" 36 | return self._instrument.state 37 | 38 | async def async_turn_on(self, **kwargs): 39 | """Turn the switch on.""" 40 | await self._instrument.turn_on() 41 | 42 | async def async_turn_off(self, **kwargs): 43 | """Turn the switch off.""" 44 | await self._instrument.turn_off() 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Contributing Code 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `master`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Issue a pull request 19 | 20 | By contributing, you agree that your contributions will be licensed under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. 21 | Feel free to contact the maintainers if that's a concern. 22 | 23 | ## Coding Style 24 | 25 | This project uses [black](https://github.com/ambv/black) to ensure the code follows a consistent style. 26 | 27 | ## Report bugs using Github's issues 28 | 29 | GitHub issues are used to track public bugs. Report a bug by [opening a new issue](../../issues/new/choose) 30 | 31 | ## Write bug reports with details 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - What you expected would happen 38 | - What actually happens 39 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 40 | -------------------------------------------------------------------------------- /custom_components/audiconnect/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Audi Connect sensors.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.binary_sensor import BinarySensorEntity 6 | from homeassistant.const import CONF_USERNAME 7 | 8 | from .audi_entity import AudiEntity 9 | from .const import DOMAIN 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 15 | """Old way.""" 16 | 17 | 18 | async def async_setup_entry(hass, config_entry, async_add_entities): 19 | sensors = [] 20 | account = config_entry.data.get(CONF_USERNAME) 21 | audiData = hass.data[DOMAIN][account] 22 | 23 | for config_vehicle in audiData.config_vehicles: 24 | for binary_sensor in config_vehicle.binary_sensors: 25 | sensors.append(AudiSensor(config_vehicle, binary_sensor)) 26 | 27 | async_add_entities(sensors) 28 | 29 | 30 | class AudiSensor(AudiEntity, BinarySensorEntity): 31 | """Representation of an Audi sensor.""" 32 | 33 | @property 34 | def is_on(self): 35 | """Return True if the binary sensor is on.""" 36 | return self._instrument.is_on 37 | 38 | @property 39 | def device_class(self): 40 | """Return the device_class of this sensor.""" 41 | return self._instrument.device_class 42 | 43 | @property 44 | def entity_category(self): 45 | """Return the entity_category.""" 46 | return self._instrument.entity_category 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /custom_components/test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import asyncio 3 | import getopt 4 | 5 | from audiconnect.audi_connect_account import AudiConnectAccount 6 | from audiconnect.dashboard import Dashboard 7 | 8 | from aiohttp import ClientSession 9 | 10 | 11 | def printHelp(): 12 | print( 13 | "test.py --user --password --spin --country " 14 | ) 15 | 16 | 17 | async def main(argv): 18 | user = "" 19 | password = "" 20 | spin = "" 21 | country = "" 22 | try: 23 | opts, _ = getopt.getopt( 24 | argv, "hu:p:s:r:", ["user=", "password=", "spin=", "country="] 25 | ) 26 | except getopt.GetoptError: 27 | printHelp() 28 | sys.exit(2) 29 | for opt, arg in opts: 30 | if opt == "-h": 31 | printHelp() 32 | sys.exit() 33 | elif opt in ("-u", "--user"): 34 | user = arg 35 | elif opt in ("-p", "--password"): 36 | password = arg 37 | elif opt in ("-s", "--spin"): 38 | spin = arg 39 | elif opt in ("-r", "--country"): 40 | country = arg 41 | 42 | if user == "" or password == "": 43 | printHelp() 44 | sys.exit() 45 | 46 | async with ClientSession() as session: 47 | account = AudiConnectAccount(session, user, password, country, spin) 48 | 49 | await account.update(None) 50 | 51 | for vehicle in account._vehicles: 52 | dashboard = Dashboard(account, vehicle, miles=True) 53 | for instrument in dashboard.instruments: 54 | print(str(instrument), instrument.str_state) 55 | 56 | 57 | if __name__ == "__main__": 58 | task = main(sys.argv[1:]) 59 | res = asyncio.get_event_loop().run_until_complete(task) 60 | -------------------------------------------------------------------------------- /custom_components/audiconnect/util.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from datetime import datetime, timezone 3 | import logging 4 | 5 | _LOGGER = logging.getLogger(__name__) 6 | 7 | 8 | def get_attr(dictionary, keys, default=None): 9 | return reduce( 10 | lambda d, key: d.get(key, default) if isinstance(d, dict) else default, 11 | keys.split("."), 12 | dictionary, 13 | ) 14 | 15 | 16 | def to_byte_array(hexString: str): 17 | result = [] 18 | for i in range(0, len(hexString), 2): 19 | result.append(int(hexString[i : i + 2], 16)) 20 | 21 | return result 22 | 23 | 24 | def log_exception(exception, message): 25 | err = message + ": " + str(exception).rstrip("\n") 26 | _LOGGER.error(err) 27 | 28 | 29 | def parse_int(val: str): 30 | try: 31 | return int(val) 32 | except (ValueError, TypeError): 33 | return None 34 | 35 | 36 | def parse_float(val: str): 37 | try: 38 | return float(val) 39 | except (ValueError, TypeError): 40 | return None 41 | 42 | 43 | def parse_datetime(time_value): 44 | """Converts timestamp to datetime object if it's a string, or returns it directly if already datetime.""" 45 | if isinstance(time_value, datetime): 46 | return time_value # Return the datetime object directly if already datetime 47 | elif isinstance(time_value, str): 48 | formats = [ 49 | "%Y-%m-%d %H:%M:%S%z", # Format: 2024-04-12 05:56:17+00:00 50 | "%Y-%m-%dT%H:%M:%S.%fZ", # Format: 2024-04-12T05:56:13.025Z 51 | ] 52 | for fmt in formats: 53 | try: 54 | return datetime.strptime(time_value, fmt).replace(tzinfo=timezone.utc) 55 | except ValueError: 56 | continue 57 | return None 58 | -------------------------------------------------------------------------------- /custom_components/audiconnect/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available services for audiconnect 2 | 3 | refresh_vehicle_data: 4 | fields: 5 | vin: 6 | required: true 7 | example: WBANXXXXXX1234567 8 | selector: 9 | text: 10 | 11 | execute_vehicle_action: 12 | fields: 13 | vin: 14 | required: true 15 | example: WBANXXXXXX1234567 16 | selector: 17 | text: 18 | action: 19 | required: true 20 | example: "lock" 21 | selector: 22 | select: 23 | translation_key: vehicle_actions 24 | options: 25 | - lock 26 | - unlock 27 | - start_climatisation 28 | - stop_climatisation 29 | - start_charger 30 | - start_timed_charger 31 | - stop_charger 32 | - start_preheater 33 | - stop_preheater 34 | - start_window_heating 35 | - stop_window_heating 36 | 37 | start_climate_control: 38 | fields: 39 | vin: 40 | required: true 41 | example: WBANXXXXXX1234567 42 | selector: 43 | text: 44 | temp_f: 45 | selector: 46 | number: 47 | min: 59 48 | max: 85 49 | temp_c: 50 | selector: 51 | number: 52 | min: 15 53 | max: 30 54 | glass_heating: 55 | selector: 56 | boolean: 57 | seat_fl: 58 | selector: 59 | boolean: 60 | seat_fr: 61 | selector: 62 | boolean: 63 | seat_rl: 64 | selector: 65 | boolean: 66 | seat_rr: 67 | selector: 68 | boolean: 69 | 70 | stop_climate_control: 71 | fields: 72 | vin: 73 | required: true 74 | example: WBANXXXXXX1234567 75 | selector: 76 | text: 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 8 * * Wed,Sun" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Gets semantic release info 13 | id: semantic_release_info 14 | uses: jossef/action-semantic-release-info@v3.0.0 15 | env: 16 | GITHUB_TOKEN: ${{ github.token }} 17 | - name: Update Version and Commit 18 | if: ${{steps.semantic_release_info.outputs.version != ''}} 19 | run: | 20 | echo "Version: ${{steps.semantic_release_info.outputs.version}}" 21 | sed -i "s/\"version\": \".*\"/\"version\": \"${{steps.semantic_release_info.outputs.version}}\"/g" custom_components/audiconnect/manifest.json 22 | git config --local user.email "action@github.com" 23 | git config --local user.name "GitHub Action" 24 | git add -A 25 | git commit -m "chore: bumping version to ${{steps.semantic_release_info.outputs.version}}" 26 | git tag ${{ steps.semantic_release_info.outputs.git_tag }} 27 | 28 | - name: Push changes 29 | if: ${{steps.semantic_release_info.outputs.version != ''}} 30 | uses: ad-m/github-push-action@v0.8.0 31 | with: 32 | github_token: ${{ github.token }} 33 | tags: true 34 | 35 | - name: Create GitHub Release 36 | if: ${{steps.semantic_release_info.outputs.version != ''}} 37 | uses: ncipollo/release-action@v1 38 | env: 39 | GITHUB_TOKEN: ${{ github.token }} 40 | with: 41 | tag: ${{ steps.semantic_release_info.outputs.git_tag }} 42 | name: ${{ steps.semantic_release_info.outputs.git_tag }} 43 | body: ${{ steps.semantic_release_info.outputs.notes }} 44 | draft: false 45 | prerelease: false 46 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | [![hacs][hacsbadge]](hacs) 2 | ![Project Maintenance][maintenance-shield] 3 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 4 | 5 | ## Configuration 6 | 7 | Configuration is done through the Home Assistant UI. 8 | 9 | To add the integration, go to `Configuration->Integrations`, click `+` and search for `Audi Connect` 10 | 11 | ![Configuration](ha_config.png) 12 | 13 | ## Configuration Variables 14 | 15 | **username** 16 | 17 | - (string)(Required)The username associated with your Audi Connect account. 18 | 19 | **password** 20 | 21 | - (string)(Required)The password for your given Audi Connect account. 22 | 23 | **S-PIN** 24 | 25 | - (string)(Optional)The S-PIN for your given Audi Connect account. 26 | 27 | **region** 28 | 29 | - (string)(Optional)The region where the Audi account is registered. Set to 'DE' for Europe (or leave unset), to 'US' for North America, or to 'CN' for China. 30 | 31 | **scan_interval** 32 | 33 | - specify in minutes how often to fetch status data from Audi Connect (optional, default 10 min, minimum 1 min) 34 | 35 | [buymecoffee]: https://buymeacoff.ee/arjenvrh 36 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20beer-donate-yellow.svg?style=for-the-badge 37 | [commits-shield]: https://img.shields.io/github/commit-activity/y/audiconnect/audi_connect_ha?style=for-the-badge 38 | [commits]: https://github.com/audiconnect/audi_connect_ha/commits/master 39 | [hacs]: https://github.com/custom-components/hacs 40 | [hacsbadge]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge 41 | [license-shield]: https://img.shields.io/github/license/arjenvrh/audi_connect_ha?style=for-the-badge 42 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Arjen%20van%20Rhijn%20%40arjenvrh-blue.svg?style=for-the-badge 43 | [blackbadge]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 44 | [black]: https://github.com/ambv/black 45 | -------------------------------------------------------------------------------- /custom_components/audiconnect/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Audi Connect sensors.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.sensor import SensorEntity 6 | from homeassistant.const import CONF_USERNAME 7 | 8 | from .audi_entity import AudiEntity 9 | from .const import DOMAIN 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 15 | """Old way.""" 16 | 17 | 18 | async def async_setup_entry(hass, config_entry, async_add_entities): 19 | sensors = [] 20 | 21 | account = config_entry.data.get(CONF_USERNAME) 22 | audiData = hass.data[DOMAIN][account] 23 | 24 | for config_vehicle in audiData.config_vehicles: 25 | for sensor in config_vehicle.sensors: 26 | sensors.append(AudiSensor(config_vehicle, sensor)) 27 | 28 | async_add_entities(sensors, True) 29 | 30 | 31 | class AudiSensor(AudiEntity, SensorEntity): 32 | """Representation of a Audi sensor.""" 33 | 34 | @property 35 | def native_value(self): 36 | """Return the native value.""" 37 | return self._instrument.state 38 | 39 | @property 40 | def native_unit_of_measurement(self): 41 | """Return the native unit of measurement.""" 42 | return self._instrument.unit 43 | 44 | @property 45 | def device_class(self): 46 | """Return the device_class.""" 47 | return self._instrument.device_class 48 | 49 | @property 50 | def state_class(self): 51 | """Return the state_class.""" 52 | return self._instrument.state_class 53 | 54 | @property 55 | def entity_category(self): 56 | """Return the entity_category.""" 57 | return self._instrument.entity_category 58 | 59 | @property 60 | def extra_state_attributes(self): 61 | """Return additional state attributes.""" 62 | return self._instrument.extra_state_attributes 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | autoupdate_commit_msg: "chore: pre-commit autoupdate" 4 | repos: 5 | - repo: https://github.com/astral-sh/ruff-pre-commit 6 | rev: v0.11.2 7 | hooks: 8 | - id: ruff 9 | args: 10 | - --fix 11 | - id: ruff-format 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | args: 17 | - --ignore-words-list=fro,hass 18 | - --skip="./.*,*.csv,*.json,*.ambr" 19 | - --quiet-level=2 20 | exclude_types: [csv, json] 21 | - repo: https://github.com/pre-commit/pre-commit-hooks 22 | rev: v5.0.0 23 | hooks: 24 | - id: trailing-whitespace 25 | - id: end-of-file-fixer 26 | - id: check-executables-have-shebangs 27 | stages: [manual] 28 | - id: check-json 29 | exclude: (.vscode|.devcontainer) 30 | - repo: https://github.com/asottile/pyupgrade 31 | rev: v3.19.1 32 | hooks: 33 | - id: pyupgrade 34 | - repo: https://github.com/adrienverge/yamllint.git 35 | rev: v1.37.0 36 | hooks: 37 | - id: yamllint 38 | exclude: (.github|.vscode|.devcontainer) 39 | - repo: https://github.com/pre-commit/mirrors-prettier 40 | rev: v4.0.0-alpha.8 41 | hooks: 42 | - id: prettier 43 | - repo: https://github.com/cdce8p/python-typing-update 44 | rev: v0.7.1 45 | hooks: 46 | # Run `python-typing-update` hook manually from time to time 47 | # to update python typing syntax. 48 | # Will require manual work, before submitting changes! 49 | # pre-commit run --hook-stage manual python-typing-update --all-files 50 | - id: python-typing-update 51 | stages: [manual] 52 | args: 53 | - --py311-plus 54 | - --force 55 | - --keep-updates 56 | files: ^(/.+)?[^/]+\.py$ 57 | - repo: https://github.com/pre-commit/mirrors-mypy 58 | rev: v1.15.0 59 | hooks: 60 | - id: mypy 61 | args: [--strict, --ignore-missing-imports] 62 | files: ^(/.+)?[^/]+\.py$ 63 | -------------------------------------------------------------------------------- /custom_components/audiconnect/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "audiconnect" 2 | 3 | CONF_VIN = "vin" 4 | CONF_CARNAME = "carname" 5 | CONF_ACTION = "action" 6 | CONF_CLIMATE_TEMP_F = "temp_f" 7 | CONF_CLIMATE_TEMP_C = "temp_c" 8 | CONF_CLIMATE_GLASS = "glass_heating" 9 | CONF_CLIMATE_SEAT_FL = "seat_fl" 10 | CONF_CLIMATE_SEAT_FR = "seat_fr" 11 | CONF_CLIMATE_SEAT_RL = "seat_rl" 12 | CONF_CLIMATE_SEAT_RR = "seat_rr" 13 | CONF_SCAN_INITIAL = "scan_initial" 14 | CONF_SCAN_ACTIVE = "scan_active" 15 | CONF_API_LEVEL = "api_level" 16 | 17 | MIN_UPDATE_INTERVAL = 15 18 | DEFAULT_UPDATE_INTERVAL = 15 19 | UPDATE_SLEEP = 5 20 | DEFAULT_API_LEVEL = 0 21 | 22 | CONF_SPIN = "spin" 23 | CONF_REGION = "region" 24 | CONF_SERVICE_URL = "service_url" 25 | CONF_MUTABLE = "mutable" 26 | 27 | SIGNAL_STATE_UPDATED = "{}.updated".format(DOMAIN) 28 | TRACKER_UPDATE = f"{DOMAIN}_tracker_update" 29 | CLIMATE_UPDATE = f"{DOMAIN}_climate_update" # Add this line for climate updates 30 | 31 | RESOURCES = [ 32 | "position", 33 | "last_update_time", 34 | "shortterm_current", 35 | "shortterm_reset", 36 | "longterm_current", 37 | "longterm_reset", 38 | "mileage", 39 | "range", 40 | "service_inspection_time", 41 | "service_inspection_distance", 42 | "service_adblue_distance", 43 | "oil_change_time", 44 | "oil_change_distance", 45 | "oil_level", 46 | "charging_state", 47 | "charging_mode", 48 | "energy_flow", 49 | "max_charge_current", 50 | "engine_type1", 51 | "engine_type2", 52 | "parking_light", 53 | "any_window_open", 54 | "any_door_unlocked", 55 | "any_door_open", 56 | "trunk_unlocked", 57 | "trunk_open", 58 | "hood_open", 59 | "tank_level", 60 | "state_of_charge", 61 | "remaining_charging_time", 62 | "plug_state", 63 | "sun_roof", 64 | "doors_trunk_status", 65 | "left_front_door_open", 66 | "right_front_door_open", 67 | "left_rear_door_open", 68 | "right_rear_door_open", 69 | "left_front_window_open", 70 | "right_front_window_open", 71 | "left_rear_window_open", 72 | "right_rear_window_open", 73 | "braking_status", 74 | "is_moving", 75 | ] 76 | 77 | COMPONENTS = { 78 | "sensor": "sensor", 79 | "binary_sensor": "binary_sensor", 80 | "lock": "lock", 81 | "device_tracker": "device_tracker", 82 | "switch": "switch", 83 | "climate": "climate", 84 | } 85 | 86 | REGION_EUROPE: str = "DE" 87 | REGION_CANADA: str = "CA" 88 | REGION_USA: str = "US" 89 | REGION_CHINA: str = "CN" 90 | 91 | REGIONS = { 92 | 1: REGION_EUROPE, 93 | 2: REGION_CANADA, 94 | 3: REGION_USA, 95 | 4: REGION_CHINA, 96 | } 97 | 98 | API_LEVELS = [0, 1] 99 | -------------------------------------------------------------------------------- /custom_components/audiconnect/audi_entity.py: -------------------------------------------------------------------------------- 1 | from homeassistant.helpers.entity import Entity 2 | from homeassistant.helpers.dispatcher import ( 3 | async_dispatcher_connect, 4 | ) 5 | from homeassistant.helpers.entity import DeviceInfo 6 | 7 | 8 | from .const import DOMAIN, SIGNAL_STATE_UPDATED 9 | 10 | 11 | class AudiEntity(Entity): 12 | """Base class for all entities.""" 13 | 14 | def __init__(self, data, instrument): 15 | """Initialize the entity.""" 16 | self._data = data 17 | self._instrument = instrument 18 | self._vin = self._instrument.vehicle_name 19 | self._component = self._instrument.component 20 | self._attribute = self._instrument.attr 21 | 22 | async def async_added_to_hass(self): 23 | """Register update dispatcher.""" 24 | async_dispatcher_connect( 25 | self.hass, SIGNAL_STATE_UPDATED, self.async_schedule_update_ha_state 26 | ) 27 | 28 | @property 29 | def icon(self): 30 | """Return the icon.""" 31 | return self._instrument.icon 32 | 33 | @property 34 | def _entity_name(self): 35 | return self._instrument.name 36 | 37 | @property 38 | def _vehicle_name(self): 39 | return self._instrument.vehicle_name 40 | 41 | @property 42 | def name(self): 43 | """Return full name of the entity.""" 44 | return "{} {}".format(self._vehicle_name, self._entity_name) 45 | 46 | @property 47 | def should_poll(self): 48 | """Return the polling state.""" 49 | return False 50 | 51 | @property 52 | def assumed_state(self): 53 | """Return true if unable to access real state of entity.""" 54 | return True 55 | 56 | @property 57 | def extra_state_attributes(self): 58 | """Return device specific state attributes.""" 59 | return dict( 60 | self._instrument.attributes, 61 | model="{}/{}".format( 62 | self._instrument.vehicle_model, self._instrument.vehicle_name 63 | ), 64 | model_year=self._instrument.vehicle_model_year, 65 | model_family=self._instrument.vehicle_model_family, 66 | title=self._instrument.vehicle_name, 67 | csid=self._instrument.vehicle_csid, 68 | vin=self._instrument.vehicle_vin, 69 | ) 70 | 71 | @property 72 | def unique_id(self): 73 | return self._instrument.full_name 74 | 75 | @property 76 | def device_info(self): 77 | if self._instrument.vehicle_model: 78 | model_info = self._instrument.vehicle_model.replace("Audi ", "") 79 | elif self._instrument.vehicle_name: 80 | model_info = self._instrument.vehicle_name 81 | else: 82 | model_info = "Unknown" 83 | return DeviceInfo( 84 | identifiers={(DOMAIN, self._instrument.vehicle_name)}, 85 | manufacturer="Audi", 86 | name=self._instrument.vehicle_name, 87 | model="{} ({})".format(model_info, self._instrument.vehicle_model_year), 88 | ) 89 | -------------------------------------------------------------------------------- /custom_components/audiconnect/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "invalid_credentials": "Informations d'identification invalides", 5 | "user_already_configured": "Le compte a déjà été configuré" 6 | }, 7 | "create_entry": {}, 8 | "error": { 9 | "invalid_credentials": "Informations d'identification invalides", 10 | "invalid_username": "Nom d'utilisateur invalide", 11 | "unexpected": "Erreur inattendue lors de la communication avec le serveur Audi Connect", 12 | "user_already_configured": "Le compte a déjà été configuré" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "password": "Mot de passe", 18 | "username": "Nom d'utilisateur", 19 | "spin": "S-PIN", 20 | "region": "Région", 21 | "scan_interval": "Intervalle de scan" 22 | }, 23 | "title": "Informations sur le compte Audi Connect" 24 | } 25 | } 26 | }, 27 | "options": { 28 | "step": { 29 | "init": { 30 | "data": { 31 | "scan_initial": "Mise à jour du cloud au démarrage", 32 | "scan_active": "Intervalle de mises à jour actives", 33 | "scan_interval": "Intervalle de mises à jour" 34 | }, 35 | "title": "Options Audi Connect", 36 | "data_description": { 37 | "scan_initial": "Effectuer une mise à jour du cloud immédiatement après le démarrage.", 38 | "scan_active": "Effectuer une mise à jour du cloud à intervalle régulier défini.", 39 | "scan_interval": "Minutes entre les mises à jour actives. Si 'Intervalle de mises à jour actives' est désactivé, cette valeur n'aura aucun impact." 40 | } 41 | } 42 | } 43 | }, 44 | "selector": { 45 | "vehicle_actions": { 46 | "options": { 47 | "lock": "Verrouiller", 48 | "unlock": "Déverrouiller", 49 | "start_climatisation": "Démarrer climatisation (Hérité)", 50 | "stop_climatisation": "Arrêter climatisation", 51 | "start_charger": "Démarrer chargeur", 52 | "start_timed_charger": "Démarrage chronométré du chargeur", 53 | "stop_charger": "Arrêter Chargeur", 54 | "start_preheater": "Démarrer préchauffage", 55 | "stop_preheater": "Arrêter préchauffage", 56 | "start_window_heating": "Démarrer chauffage des fenêtres", 57 | "stop_window_heating": "Arrêter chauffage des fenêtres" 58 | } 59 | } 60 | }, 61 | "services": { 62 | "refresh_vehicle_data": { 63 | "name": "Actualiser les données du véhicule", 64 | "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.", 65 | "fields": { 66 | "vin": { 67 | "name": "VIN", 68 | "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." 69 | } 70 | } 71 | }, 72 | "execute_vehicle_action": { 73 | "name": "Exécuter l'action du véhicule", 74 | "description": "Effectue diverses actions sur le véhicule.", 75 | "fields": { 76 | "vin": { 77 | "name": "VIN", 78 | "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." 79 | }, 80 | "action": { 81 | "name": "Action", 82 | "description": "L'action spécifique à effectuer sur le véhicule. Noter que les actions disponibles peuvent varier en fonction du véhicule.", 83 | "example": "Verrouiller" 84 | } 85 | } 86 | }, 87 | "start_climate_control": { 88 | "name": "Démarrer la climatisation", 89 | "description": "Démarrez la climatisation avec des options de température, de chauffage des vitres et de confort des sièges auto.", 90 | "fields": { 91 | "vin": { 92 | "name": "VIN", 93 | "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." 94 | }, 95 | "temp_f": { 96 | "name": "Température cible (Fahrenheit)", 97 | "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'." 98 | }, 99 | "temp_c": { 100 | "name": "Température cible (Celsius)", 101 | "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." 102 | }, 103 | "glass_heating": { 104 | "name": "Chauffage des vitres", 105 | "description": "(Facultatif) Activer ou désactiver le chauffage des surfaces vitrées." 106 | }, 107 | "seat_fl": { 108 | "name": "Confort du siège auto: Avant-Gauche", 109 | "description": "(Facultatif) Activer ou désactiver Confort du siège auto pour le siège avant gauche." 110 | }, 111 | "seat_fr": { 112 | "name": "Confort du siège auto: Avant-Droit", 113 | "description": "(Facultatif) Activer ou désactiver Confort du siège auto pour le siège avant droit." 114 | }, 115 | "seat_rl": { 116 | "name": "Confort du siège auto: Arrière-Gauche", 117 | "description": "(Facultatif) Activer ou désactiver Confort du siège auto pour le siège arrière gauche." 118 | }, 119 | "seat_rr": { 120 | "name": "Confort du siège auto: Arrière-Droit", 121 | "description": "(Facultatif) Activer ou désactiver Confort du siège auto pour le siège arrière droit." 122 | } 123 | } 124 | }, 125 | "refresh_cloud_data": { 126 | "name": "Actualiser les données cloud", 127 | "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." 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /custom_components/audiconnect/audi_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from datetime import datetime 4 | 5 | import asyncio 6 | 7 | from asyncio import TimeoutError, CancelledError 8 | from aiohttp import ClientResponseError 9 | from aiohttp.hdrs import METH_GET, METH_POST, METH_PUT 10 | 11 | from typing import Dict 12 | 13 | TIMEOUT = 30 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class AudiAPI: 19 | HDR_XAPP_VERSION = "4.31.0" 20 | HDR_USER_AGENT = "Android/4.31.0 (Build 800341641.root project 'myaudi_android'.ext.buildTime) Android/13" 21 | 22 | def __init__(self, session, proxy=None): 23 | self.__token = None 24 | self.__xclientid = None 25 | self._session = session 26 | if proxy is not None: 27 | self.__proxy = {"http": proxy, "https": proxy} 28 | else: 29 | self.__proxy = None 30 | 31 | def use_token(self, token): 32 | self.__token = token 33 | 34 | def set_xclient_id(self, xclientid): 35 | self.__xclientid = xclientid 36 | 37 | async def request( 38 | self, 39 | method, 40 | url, 41 | data, 42 | headers: Dict[str, str] = None, 43 | raw_reply: bool = False, 44 | raw_contents: bool = False, 45 | rsp_wtxt: bool = False, 46 | **kwargs, 47 | ): 48 | _LOGGER.debug( 49 | "Request initiated: method=%s, url=%s, data=%s, headers=%s, kwargs=%s", 50 | method, 51 | url, 52 | data, 53 | headers, 54 | kwargs, 55 | ) 56 | try: 57 | async with asyncio.timeout(TIMEOUT): 58 | async with self._session.request( 59 | method, url, headers=headers, data=data, **kwargs 60 | ) as response: 61 | # _LOGGER.debug("Response received: status=%s, headers=%s", response.status, response.headers) 62 | if raw_reply: 63 | # _LOGGER.debug("Returning raw reply") 64 | return response 65 | if rsp_wtxt: 66 | txt = await response.text() 67 | # _LOGGER.debug("Returning response text; length=%d", len(txt)) 68 | return response, txt 69 | elif raw_contents: 70 | contents = await response.read() 71 | # _LOGGER.debug("Returning raw contents; length=%d", len(contents)) 72 | return contents 73 | elif response.status in (200, 202, 207): 74 | json_data = await response.json(loads=json_loads) 75 | # _LOGGER.debug("Returning JSON data: %s", json_data) 76 | return json_data 77 | else: 78 | # _LOGGER.error("Unexpected response: status=%s, reason=%s", response.status, response.reason) 79 | raise ClientResponseError( 80 | response.request_info, 81 | response.history, 82 | status=response.status, 83 | message=response.reason, 84 | ) 85 | except CancelledError: 86 | # _LOGGER.error("Request cancelled (Timeout error)") 87 | raise TimeoutError("Timeout error") 88 | except TimeoutError: 89 | # _LOGGER.error("Request timed out") 90 | raise TimeoutError("Timeout error") 91 | except Exception: 92 | # _LOGGER.exception("An unexpected error occurred during request") 93 | raise 94 | 95 | async def get( 96 | self, url, raw_reply: bool = False, raw_contents: bool = False, **kwargs 97 | ): 98 | full_headers = self.__get_headers() 99 | r = await self.request( 100 | METH_GET, 101 | url, 102 | data=None, 103 | headers=full_headers, 104 | raw_reply=raw_reply, 105 | raw_contents=raw_contents, 106 | **kwargs, 107 | ) 108 | return r 109 | 110 | async def put(self, url, data=None, headers: Dict[str, str] = None): 111 | full_headers = self.__get_headers() 112 | if headers is not None: 113 | full_headers.update(headers) 114 | r = await self.request(METH_PUT, url, headers=full_headers, data=data) 115 | return r 116 | 117 | async def post( 118 | self, 119 | url, 120 | data=None, 121 | headers: Dict[str, str] = None, 122 | use_json: bool = True, 123 | raw_reply: bool = False, 124 | raw_contents: bool = False, 125 | **kwargs, 126 | ): 127 | full_headers = self.__get_headers() 128 | if headers is not None: 129 | full_headers.update(headers) 130 | if use_json and data is not None: 131 | data = json.dumps(data) 132 | r = await self.request( 133 | METH_POST, 134 | url, 135 | headers=full_headers, 136 | data=data, 137 | raw_reply=raw_reply, 138 | raw_contents=raw_contents, 139 | **kwargs, 140 | ) 141 | return r 142 | 143 | def __get_headers(self): 144 | data = { 145 | "Accept": "application/json", 146 | "Accept-Charset": "utf-8", 147 | "X-App-Version": self.HDR_XAPP_VERSION, 148 | "X-App-Name": "myAudi", 149 | "User-Agent": self.HDR_USER_AGENT, 150 | } 151 | if self.__token is not None: 152 | data["Authorization"] = "Bearer " + self.__token.get("access_token") 153 | if self.__xclientid is not None: 154 | data["X-Client-ID"] = self.__xclientid 155 | 156 | return data 157 | 158 | 159 | def obj_parser(obj): 160 | """Parse datetime.""" 161 | for key, val in obj.items(): 162 | try: 163 | obj[key] = datetime.strptime(val, "%Y-%m-%dT%H:%M:%S%z") 164 | except (TypeError, ValueError): 165 | pass 166 | return obj 167 | 168 | 169 | def json_loads(s): 170 | return json.loads(s, object_hook=obj_parser) 171 | -------------------------------------------------------------------------------- /custom_components/audiconnect/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "invalid_credentials": "Invalid credentials", 5 | "user_already_configured": "Account has already been configured" 6 | }, 7 | "create_entry": {}, 8 | "error": { 9 | "invalid_credentials": "Invalid credentials", 10 | "invalid_username": "Invalid username", 11 | "unexpected": "Unexpected error communicating with Audi Connect server", 12 | "user_already_configured": "Account has already been configured" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "password": "Password", 18 | "username": "Username", 19 | "spin": "S-PIN", 20 | "region": "Region", 21 | "scan_interval": "Scan interval", 22 | "api_level": "API Level" 23 | }, 24 | "title": "Audi Connect Account Info", 25 | "data_description": { 26 | "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." 27 | } 28 | } 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "init": { 34 | "data": { 35 | "scan_initial": "Cloud Update at Startup", 36 | "scan_active": "Active Polling at Scan Interval", 37 | "scan_interval": "Scan Interval", 38 | "api_level": "API Level" 39 | }, 40 | "title": "Audi Connect Options", 41 | "data_description": { 42 | "scan_initial": "Perform a cloud update immediately upon startup.", 43 | "scan_active": "Perform a cloud update at the set scan interval.", 44 | "scan_interval": "Minutes between active polling. If 'Active Polling at Scan Interval' is off, this value will have no impact.", 45 | "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." 46 | } 47 | } 48 | } 49 | }, 50 | "selector": { 51 | "vehicle_actions": { 52 | "options": { 53 | "lock": "Lock", 54 | "unlock": "Unlock", 55 | "start_climatisation": "Start Climatisation (Legacy)", 56 | "stop_climatisation": "Stop Climatisation", 57 | "start_charger": "Start Charger", 58 | "start_timed_charger": "Start timed Charger", 59 | "stop_charger": "Stop Charger", 60 | "start_preheater": "Start Preheater", 61 | "stop_preheater": "Stop Preheater", 62 | "start_window_heating": "Start Window heating", 63 | "stop_window_heating": "Stop Windows heating", 64 | "is_moving": "Is moving" 65 | } 66 | } 67 | }, 68 | "services": { 69 | "refresh_vehicle_data": { 70 | "name": "Refresh Vehicle Data", 71 | "description": "Requests an update of the vehicle state directly, as opposed to the normal update mechanism which only retrieves data from the cloud.", 72 | "fields": { 73 | "vin": { 74 | "name": "VIN", 75 | "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." 76 | } 77 | } 78 | }, 79 | "execute_vehicle_action": { 80 | "name": "Execute Vehicle Action", 81 | "description": "Performs various actions on the vehicle.", 82 | "fields": { 83 | "vin": { 84 | "name": "VIN", 85 | "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." 86 | }, 87 | "action": { 88 | "name": "Action", 89 | "description": "The specific action to perform on the vehicle. Note that available actions may vary based on the vehicle.", 90 | "example": "lock" 91 | } 92 | } 93 | }, 94 | "start_climate_control": { 95 | "name": "Start Climate Control", 96 | "description": "Start the climate control with options for temperature, glass surface heating, and auto seat comfort.", 97 | "fields": { 98 | "vin": { 99 | "name": "VIN", 100 | "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." 101 | }, 102 | "temp_f": { 103 | "name": "Target Temperature (Fahrenheit)", 104 | "description": "(Optional) Set temperature in °F. Defaults to 70°F if not provided. Overrides 'temp_c'." 105 | }, 106 | "temp_c": { 107 | "name": "Target Temperature (Celsius)", 108 | "description": "(Optional) Set temperature in °C. Defaults to 21°C if not provided. Overridden if 'temp_f' is provided." 109 | }, 110 | "glass_heating": { 111 | "name": "Glass Surface Heating", 112 | "description": "(Optional) Enable or disable glass surface heating." 113 | }, 114 | "seat_fl": { 115 | "name": "Auto Seat Comfort: Front-Left", 116 | "description": "(Optional) Enable or disable Auto Seat Comfort for the front-left seat." 117 | }, 118 | "seat_fr": { 119 | "name": "Auto Seat Comfort: Front-Right", 120 | "description": "(Optional) Enable or disable Auto Seat Comfort for the front-right seat." 121 | }, 122 | "seat_rl": { 123 | "name": "Auto Seat Comfort: Rear-Left", 124 | "description": "(Optional) Enable or disable Auto Seat Comfort for the rear-left seat." 125 | }, 126 | "seat_rr": { 127 | "name": "Auto Seat Comfort: Rear-Right", 128 | "description": "(Optional) Enable or disable Auto Seat Comfort for the rear-right seat." 129 | } 130 | } 131 | }, 132 | "refresh_cloud_data": { 133 | "name": "Refresh Cloud Data", 134 | "description": "Retrieves current cloud data without triggering a vehicle refresh. Data may be outdated if the vehicle has not checked in recently." 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /custom_components/audiconnect/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for Audi Connect.""" 2 | 3 | from datetime import timedelta 4 | import voluptuous as vol 5 | import logging 6 | 7 | import homeassistant.helpers.config_validation as cv 8 | from homeassistant.helpers.event import async_track_time_interval 9 | from homeassistant.util.dt import utcnow 10 | from homeassistant import config_entries 11 | from homeassistant.const import ( 12 | CONF_NAME, 13 | CONF_PASSWORD, 14 | CONF_RESOURCES, 15 | CONF_SCAN_INTERVAL, 16 | CONF_USERNAME, 17 | ) 18 | 19 | from .audi_account import AudiAccount 20 | 21 | from .const import ( 22 | DOMAIN, 23 | CONF_REGION, 24 | CONF_MUTABLE, 25 | CONF_SCAN_INITIAL, 26 | CONF_SCAN_ACTIVE, 27 | DEFAULT_UPDATE_INTERVAL, 28 | MIN_UPDATE_INTERVAL, 29 | RESOURCES, 30 | COMPONENTS, 31 | CONF_API_LEVEL, 32 | DEFAULT_API_LEVEL, 33 | API_LEVELS, 34 | ) 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | CONFIG_SCHEMA = vol.Schema( 39 | { 40 | DOMAIN: vol.Schema( 41 | { 42 | vol.Required(CONF_USERNAME): cv.string, 43 | vol.Required(CONF_PASSWORD): cv.string, 44 | vol.Optional( 45 | CONF_SCAN_INTERVAL, 46 | default=timedelta(minutes=DEFAULT_UPDATE_INTERVAL), 47 | ): vol.All( 48 | cv.time_period, 49 | vol.Clamp(min=timedelta(minutes=MIN_UPDATE_INTERVAL)), 50 | ), 51 | vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( 52 | cv.string 53 | ), 54 | vol.Optional(CONF_RESOURCES): vol.All( 55 | cv.ensure_list, [vol.In(RESOURCES)] 56 | ), 57 | vol.Optional(CONF_REGION): cv.string, 58 | vol.Optional(CONF_MUTABLE, default=True): cv.boolean, 59 | vol.Optional( 60 | CONF_API_LEVEL, default=API_LEVELS[DEFAULT_API_LEVEL] 61 | ): vol.All(vol.Coerce(int), vol.In(API_LEVELS)), 62 | } 63 | ) 64 | }, 65 | extra=vol.ALLOW_EXTRA, 66 | ) 67 | 68 | 69 | async def async_setup(hass, config): 70 | if hass.config_entries.async_entries(DOMAIN): 71 | return True 72 | 73 | if DOMAIN not in config: 74 | return True 75 | 76 | names = config[DOMAIN].get(CONF_NAME) 77 | if len(names) == 0: 78 | return True 79 | 80 | data = {} 81 | data[CONF_USERNAME] = config[DOMAIN].get(CONF_USERNAME) 82 | data[CONF_PASSWORD] = config[DOMAIN].get(CONF_PASSWORD) 83 | data[CONF_SCAN_INTERVAL] = config[DOMAIN].get(CONF_SCAN_INTERVAL).seconds / 60 84 | data[CONF_REGION] = config[DOMAIN].get(CONF_REGION) 85 | data[CONF_API_LEVEL] = config[DOMAIN].get(CONF_API_LEVEL) 86 | 87 | hass.async_create_task( 88 | hass.config_entries.flow.async_init( 89 | DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data 90 | ) 91 | ) 92 | 93 | return True 94 | 95 | 96 | async def async_update_listener(hass, config_entry): 97 | _LOGGER.debug("Updates detected, reloading configuration...") 98 | await hass.config_entries.async_reload(config_entry.entry_id) 99 | 100 | 101 | async def async_setup_entry(hass, config_entry): 102 | """Set up this integration using UI.""" 103 | _LOGGER.debug("Audi Connect starting...") 104 | 105 | # Register the update listener so that changes to configuration options are applied immediately. 106 | config_entry.async_on_unload( 107 | config_entry.add_update_listener(async_update_listener) 108 | ) 109 | 110 | if DOMAIN not in hass.data: 111 | hass.data[DOMAIN] = {} 112 | 113 | """Set up the Audi Connect component.""" 114 | hass.data[DOMAIN]["devices"] = set() 115 | 116 | # Attempt to retrieve the scan interval from options, then fall back to data, or use default 117 | scan_interval = timedelta( 118 | minutes=config_entry.options.get( 119 | CONF_SCAN_INTERVAL, 120 | config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_UPDATE_INTERVAL), 121 | ) 122 | ) 123 | _LOGGER.debug("User option for CONF_SCAN_INTERVAL is %s", scan_interval) 124 | 125 | # Get Initial Scan Option - Default to True 126 | _scan_initial = config_entry.options.get(CONF_SCAN_INITIAL, True) 127 | _LOGGER.debug("User option for CONF_SCAN_INITIAL is %s.", _scan_initial) 128 | 129 | # Get Active Scan Option - Default to True 130 | _scan_active = config_entry.options.get(CONF_SCAN_ACTIVE, True) 131 | _LOGGER.debug("User option for CONF_SCAN_ACTIVE is %s.", _scan_active) 132 | 133 | account = config_entry.data.get(CONF_USERNAME) 134 | 135 | if account not in hass.data[DOMAIN]: 136 | data = hass.data[DOMAIN][account] = AudiAccount(hass, config_entry) 137 | data.init_connection() 138 | else: 139 | data = hass.data[DOMAIN][account] 140 | 141 | # Define a callback function for the timer to update data 142 | async def update_data(now): 143 | """Update the data with the latest information.""" 144 | _LOGGER.debug("ACTIVE POLLING: Requesting scheduled cloud data refresh...") 145 | await data.update(utcnow()) 146 | 147 | # Schedule the update_data function if option is true 148 | if _scan_active: 149 | _LOGGER.debug( 150 | "ACTIVE POLLING: Scheduling cloud data refresh every %d minutes.", 151 | scan_interval.seconds / 60, 152 | ) 153 | async_track_time_interval(hass, update_data, scan_interval) 154 | else: 155 | _LOGGER.debug( 156 | "ACTIVE POLLING: Active Polling at Scan Interval is turned off in user options. Skipping scheduling..." 157 | ) 158 | 159 | # Initially update the data if option is true 160 | if _scan_initial: 161 | _LOGGER.debug("Requesting initial cloud data update...") 162 | return await data.update(utcnow()) 163 | else: 164 | _LOGGER.debug( 165 | "Cloud Update at Start is turned off in user options. Skipping initial update..." 166 | ) 167 | 168 | _LOGGER.debug("Audi Connect Setup Complete") 169 | return True 170 | 171 | 172 | async def async_unload_entry(hass, config_entry): 173 | account = config_entry.data.get(CONF_USERNAME) 174 | 175 | data = hass.data[DOMAIN][account] 176 | 177 | for component in COMPONENTS: 178 | await hass.config_entries.async_forward_entry_unload( 179 | data.config_entry, component 180 | ) 181 | 182 | del hass.data[DOMAIN][account] 183 | 184 | return True 185 | -------------------------------------------------------------------------------- /custom_components/audiconnect/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "invalid_credentials": "Invalid credentials", 5 | "user_already_configured": "Account has already been configured" 6 | }, 7 | "create_entry": {}, 8 | "error": { 9 | "invalid_credentials": "Invalid credentials", 10 | "invalid_username": "Invalid username", 11 | "unexpected": "Unexpected error communicating with Audi Connect server", 12 | "user_already_configured": "Account has already been configured" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "password": "Password", 18 | "username": "Username", 19 | "spin": "S-PIN", 20 | "region": "Region", 21 | "scan_interval": "Scan interval", 22 | "api_level": "API Level" 23 | }, 24 | "title": "Audi Connect Account Info", 25 | "data_description": { 26 | "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." 27 | } 28 | } 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "init": { 34 | "data": { 35 | "scan_initial": "Cloud Update at Startup", 36 | "scan_active": "Active Polling at Scan Interval", 37 | "scan_interval": "Scan Interval", 38 | "api_level": "API Level" 39 | }, 40 | "title": "Audi Connect Options", 41 | "data_description": { 42 | "scan_initial": "Perform a cloud update immediately upon startup.", 43 | "scan_active": "Perform a cloud update at the set scan interval.", 44 | "scan_interval": "Minutes between active polling. If 'Active Polling at Scan Interval' is off, this value will have no impact.", 45 | "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." 46 | } 47 | } 48 | } 49 | }, 50 | "selector": { 51 | "vehicle_actions": { 52 | "options": { 53 | "lock": "Lock", 54 | "unlock": "Unlock", 55 | "start_climatisation": "Start Climatisation (Legacy)", 56 | "stop_climatisation": "Stop Climatisation", 57 | "start_charger": "Start Charger", 58 | "start_timed_charger": "Start timed Charger", 59 | "stop_charger": "Stop Charger", 60 | "start_preheater": "Start Preheater", 61 | "stop_preheater": "Stop Preheater", 62 | "start_window_heating": "Start Window heating", 63 | "stop_window_heating": "Stop Windows heating", 64 | "is_moving": "Is moving" 65 | } 66 | } 67 | }, 68 | "services": { 69 | "refresh_vehicle_data": { 70 | "name": "Refresh Vehicle Data", 71 | "description": "Requests an update of the vehicle state directly, as opposed to the normal update mechanism which only retrieves data from the cloud.", 72 | "fields": { 73 | "vin": { 74 | "name": "VIN", 75 | "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." 76 | } 77 | } 78 | }, 79 | "execute_vehicle_action": { 80 | "name": "Execute Vehicle Action", 81 | "description": "Performs various actions on the vehicle.", 82 | "fields": { 83 | "vin": { 84 | "name": "VIN", 85 | "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." 86 | }, 87 | "action": { 88 | "name": "Action", 89 | "description": "The specific action to perform on the vehicle. Note that available actions may vary based on the vehicle.", 90 | "example": "lock" 91 | } 92 | } 93 | }, 94 | "start_climate_control": { 95 | "name": "Start Climate Control", 96 | "description": "Start the climate control with options for temperature, glass surface heating, and auto seat comfort.", 97 | "fields": { 98 | "vin": { 99 | "name": "VIN", 100 | "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." 101 | }, 102 | "temp_f": { 103 | "name": "Target Temperature (Fahrenheit)", 104 | "description": "(Optional) Set temperature in °F. Defaults to 70°F if not provided. Overrides 'temp_c'." 105 | }, 106 | "temp_c": { 107 | "name": "Target Temperature (Celsius)", 108 | "description": "(Optional) Set temperature in °C. Defaults to 21°C if not provided. Overridden if 'temp_f' is provided." 109 | }, 110 | "glass_heating": { 111 | "name": "Glass Surface Heating", 112 | "description": "(Optional) Enable or disable glass surface heating." 113 | }, 114 | "seat_fl": { 115 | "name": "Auto Seat Comfort: Front-Left", 116 | "description": "(Optional) Enable or disable Auto Seat Comfort for the front-left seat." 117 | }, 118 | "seat_fr": { 119 | "name": "Auto Seat Comfort: Front-Right", 120 | "description": "(Optional) Enable or disable Auto Seat Comfort for the front-right seat." 121 | }, 122 | "seat_rl": { 123 | "name": "Auto Seat Comfort: Rear-Left", 124 | "description": "(Optional) Enable or disable Auto Seat Comfort for the rear-left seat." 125 | }, 126 | "seat_rr": { 127 | "name": "Auto Seat Comfort: Rear-Right", 128 | "description": "(Optional) Enable or disable Auto Seat Comfort for the rear-right seat." 129 | } 130 | } 131 | }, 132 | "stop_climate_control": { 133 | "name": "Stop Climate Control", 134 | "description": "Stop the climate control.", 135 | "fields": { 136 | "vin": { 137 | "name": "VIN", 138 | "description": "The Vehicle Identification Number (VIN) of the Audi vehicle. This should be a 17-character identifier unique to each vehicle." 139 | } 140 | } 141 | }, 142 | "refresh_cloud_data": { 143 | "name": "Refresh Cloud Data", 144 | "description": "Retrieves current cloud data without triggering a vehicle refresh. Data may be outdated if the vehicle has not checked in recently." 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/audiconnect/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen", 5 | "user_already_configured": "Konto wurde bereits konfiguriert" 6 | }, 7 | "create_entry": {}, 8 | "error": { 9 | "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen", 10 | "invalid_username": "Ung\u00fcltiger Benutzername", 11 | "unexpected": "Unerwarteter Fehler bei der Kommunikation mit dem Audi Connect Server", 12 | "user_already_configured": "Konto wurde bereits konfiguriert" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "password": "Passwort", 18 | "username": "Benutzername", 19 | "spin": "S-PIN", 20 | "region": "Region", 21 | "scan_interval": "Abfrageintervall", 22 | "api_level": "API-Level" 23 | }, 24 | "title": "Audi Connect Kontoinformationen", 25 | "data_description": { 26 | "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." 27 | } 28 | } 29 | } 30 | }, 31 | "options": { 32 | "step": { 33 | "init": { 34 | "data": { 35 | "scan_initial": "Cloud-Update beim Start", 36 | "scan_active": "Aktive Abfrage im Scanintervall", 37 | "scan_interval": "Abfrageintervall", 38 | "api_level": "API-Level" 39 | }, 40 | "title": "Audi Connect-Optionen", 41 | "data_description": { 42 | "scan_initial": "Führen Sie sofort nach dem Start ein Cloud-Update durch.", 43 | "scan_active": "Führen Sie im festgelegten Scanintervall ein Cloud-Update durch.", 44 | "scan_interval": "Minuten zwischen aktiven Abfragen. Wenn „Aktive Abfrage im Scanintervall“ deaktiviert ist, hat dieser Wert keine Auswirkung.", 45 | "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." 46 | } 47 | } 48 | } 49 | }, 50 | "selector": { 51 | "vehicle_actions": { 52 | "options": { 53 | "lock": "Sperren", 54 | "unlock": "Freischalten", 55 | "start_climatisation": "Klimatisierung starten (Legacy)", 56 | "stop_climatisation": "Schluss mit der Klimatisierung", 57 | "start_charger": "Ladegerät starten", 58 | "start_timed_charger": "Starten Sie das zeitgesteuerte Ladegerät", 59 | "stop_charger": "Stoppen Sie das Ladegerät", 60 | "start_preheater": "Vorwärmer starten", 61 | "stop_preheater": "Stoppen Sie den Vorwärmer", 62 | "start_window_heating": "Fensterheizung starten", 63 | "stop_window_heating": "Stoppen Sie die Fensterheizung", 64 | "is_moving": "In Bewegung" 65 | } 66 | } 67 | }, 68 | "services": { 69 | "refresh_vehicle_data": { 70 | "name": "Fahrzeugdaten aktualisieren", 71 | "description": "Fordert direkt eine Aktualisierung des Fahrzeugstatus an, im Gegensatz zum normalen Aktualisierungsmechanismus, der nur Daten aus der Cloud abruft.", 72 | "fields": { 73 | "vin": { 74 | "name": "VIN", 75 | "description": "Die Fahrzeugidentifikationsnummer (VIN) des Audi-Fahrzeugs. Dies sollte eine 17-stellige Kennung sein, die für jedes Fahrzeug eindeutig ist." 76 | } 77 | } 78 | }, 79 | "execute_vehicle_action": { 80 | "name": "Fahrzeugaktionen ausfuhren", 81 | "description": "Führt verschiedene Aktionen am Fahrzeug aus.", 82 | "fields": { 83 | "vin": { 84 | "name": "VIN", 85 | "description": "Die Fahrzeugidentifikationsnummer (VIN) des Audi-Fahrzeugs. Dies sollte eine 17-stellige Kennung sein, die für jedes Fahrzeug eindeutig ist." 86 | }, 87 | "action": { 88 | "name": "Aktion", 89 | "description": "Die spezifische Aktion, die am Fahrzeug ausgeführt werden soll. Beachten Sie, dass die verfügbaren Aktionen je nach Fahrzeug variieren können.", 90 | "example": "lock" 91 | } 92 | } 93 | }, 94 | "start_climate_control": { 95 | "name": "Starten Sie die Klimatisierung", 96 | "description": "Starten Sie die Klimaanlage mit Optionen für Temperatur, Glasflächenheizung und automatischen Sitzkomfort.", 97 | "fields": { 98 | "vin": { 99 | "name": "VIN", 100 | "description": "Die Fahrzeugidentifikationsnummer (VIN) des Audi-Fahrzeugs. Dies sollte eine 17-stellige Kennung sein, die für jedes Fahrzeug eindeutig ist." 101 | }, 102 | "temp_f": { 103 | "name": "Zieltemperatur (Fahrenheit)", 104 | "description": "(Optional) Stellen Sie die Temperatur in °F ein. Standardmäßig 70 °F, sofern nicht angegeben. Überschreibt 'temp_c'." 105 | }, 106 | "temp_c": { 107 | "name": "Zieltemperatur (Celsius)", 108 | "description": "(Optional) Stellen Sie die Temperatur in °C ein. Standardmäßig 21 °C, sofern nicht angegeben. Wird überschrieben, wenn „temp_f“ bereitgestellt wird." 109 | }, 110 | "glass_heating": { 111 | "name": "Glasflächenheizung", 112 | "description": "(Optional) Aktivieren oder deaktivieren Sie die Glasflächenheizung." 113 | }, 114 | "seat_fl": { 115 | "name": "Automatischer Sitzkomfort: Vorne links", 116 | "description": "(Optional) Aktivieren oder deaktivieren Sie den automatischen Sitzkomfort für den vorderen linken Sitz." 117 | }, 118 | "seat_fr": { 119 | "name": "Automatischer Sitzkomfort: Vorne rechts", 120 | "description": "(Optional) Aktivieren oder deaktivieren Sie den automatischen Sitzkomfort für den Vordersitz rechts." 121 | }, 122 | "seat_rl": { 123 | "name": "Automatischer Sitzkomfort: Hinten links", 124 | "description": "(Optional) Aktivieren oder deaktivieren Sie den automatischen Sitzkomfort für den linken Rücksitz." 125 | }, 126 | "seat_rr": { 127 | "name": "Automatischer Sitzkomfort: Hinten rechts", 128 | "description": "(Optional) Aktivieren oder deaktivieren Sie den automatischen Sitzkomfort für den rechten Rücksitz." 129 | } 130 | } 131 | }, 132 | "refresh_cloud_data": { 133 | "name": "Cloud-Daten aktualisieren", 134 | "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." 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /custom_components/audiconnect/config_flow.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import logging 3 | import voluptuous as vol 4 | 5 | from homeassistant import config_entries 6 | from homeassistant.const import ( 7 | CONF_PASSWORD, 8 | CONF_USERNAME, 9 | CONF_REGION, 10 | CONF_SCAN_INTERVAL, 11 | ) 12 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 13 | from homeassistant.core import callback 14 | 15 | from .audi_connect_account import AudiConnectAccount 16 | from .const import ( 17 | DOMAIN, 18 | CONF_SPIN, 19 | DEFAULT_UPDATE_INTERVAL, 20 | MIN_UPDATE_INTERVAL, 21 | CONF_SCAN_INITIAL, 22 | CONF_SCAN_ACTIVE, 23 | REGIONS, 24 | CONF_API_LEVEL, 25 | DEFAULT_API_LEVEL, 26 | API_LEVELS, 27 | ) 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | @callback 33 | def configured_accounts(hass): 34 | """Return tuple of configured usernames.""" 35 | entries = hass.config_entries.async_entries(DOMAIN) 36 | if entries: 37 | return (entry.data[CONF_USERNAME] for entry in entries) 38 | return () 39 | 40 | 41 | @config_entries.HANDLERS.register(DOMAIN) 42 | class AudiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 43 | def __init__(self): 44 | """Initialize.""" 45 | self._username = vol.UNDEFINED 46 | self._password = vol.UNDEFINED 47 | self._spin = vol.UNDEFINED 48 | self._region = vol.UNDEFINED 49 | self._scan_interval = DEFAULT_UPDATE_INTERVAL 50 | self._api_level = DEFAULT_API_LEVEL 51 | 52 | async def async_step_user(self, user_input=None): 53 | """Handle a user initiated config flow.""" 54 | errors = {} 55 | 56 | if user_input is not None: 57 | self._username = user_input[CONF_USERNAME] 58 | self._password = user_input[CONF_PASSWORD] 59 | self._spin = user_input.get(CONF_SPIN) 60 | self._region = REGIONS[user_input.get(CONF_REGION)] 61 | self._scan_interval = user_input[CONF_SCAN_INTERVAL] 62 | self._api_level = user_input[CONF_API_LEVEL] 63 | 64 | try: 65 | # pylint: disable=no-value-for-parameter 66 | session = async_get_clientsession(self.hass) 67 | connection = AudiConnectAccount( 68 | session=session, 69 | username=vol.Email()(self._username), 70 | password=self._password, 71 | country=self._region, 72 | spin=self._spin, 73 | api_level=self._api_level, 74 | ) 75 | 76 | if await connection.try_login(False) is False: 77 | raise Exception( 78 | "Unexpected error communicating with the Audi server" 79 | ) 80 | 81 | except vol.Invalid: 82 | errors[CONF_USERNAME] = "invalid_username" 83 | except Exception: 84 | errors["base"] = "invalid_credentials" 85 | else: 86 | if self._username in configured_accounts(self.hass): 87 | errors["base"] = "user_already_configured" 88 | else: 89 | return self.async_create_entry( 90 | title=f"{self._username}", 91 | data={ 92 | CONF_USERNAME: self._username, 93 | CONF_PASSWORD: self._password, 94 | CONF_SPIN: self._spin, 95 | CONF_REGION: self._region, 96 | CONF_SCAN_INTERVAL: self._scan_interval, 97 | CONF_API_LEVEL: self._api_level, 98 | }, 99 | ) 100 | 101 | data_schema = OrderedDict() 102 | data_schema[vol.Required(CONF_USERNAME, default=self._username)] = str 103 | data_schema[vol.Required(CONF_PASSWORD, default=self._password)] = str 104 | data_schema[vol.Optional(CONF_SPIN, default=self._spin)] = str 105 | data_schema[vol.Required(CONF_REGION, default=self._region)] = vol.In(REGIONS) 106 | data_schema[ 107 | vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL) 108 | ] = int 109 | data_schema[ 110 | vol.Optional(CONF_API_LEVEL, default=API_LEVELS[DEFAULT_API_LEVEL]) 111 | ] = vol.All(vol.Coerce(int), vol.In(API_LEVELS)) 112 | 113 | return self.async_show_form( 114 | step_id="user", 115 | data_schema=vol.Schema(data_schema), 116 | errors=errors, 117 | ) 118 | 119 | async def async_step_import(self, user_input): 120 | """Import a config flow from configuration.""" 121 | username = user_input[CONF_USERNAME] 122 | password = user_input[CONF_PASSWORD] 123 | api_level = user_input[CONF_API_LEVEL] 124 | 125 | spin = None 126 | if user_input.get(CONF_SPIN): 127 | spin = user_input[CONF_SPIN] 128 | 129 | region = "DE" 130 | if user_input.get(CONF_REGION): 131 | region = REGIONS[user_input.get(CONF_REGION)] 132 | 133 | scan_interval = DEFAULT_UPDATE_INTERVAL 134 | 135 | if user_input.get(CONF_SCAN_INTERVAL): 136 | scan_interval = user_input[CONF_SCAN_INTERVAL] 137 | 138 | if scan_interval < MIN_UPDATE_INTERVAL: 139 | scan_interval = MIN_UPDATE_INTERVAL 140 | 141 | try: 142 | session = async_get_clientsession(self.hass) 143 | connection = AudiConnectAccount( 144 | session=session, 145 | username=username, 146 | password=password, 147 | country=region, 148 | spin=spin, 149 | api_level=api_level, 150 | ) 151 | 152 | if await connection.try_login(False) is False: 153 | raise Exception("Unexpected error communicating with the Audi server") 154 | 155 | except Exception: 156 | _LOGGER.error("Invalid credentials for %s", username) 157 | return self.async_abort(reason="invalid_credentials") 158 | 159 | return self.async_create_entry( 160 | title=f"{username} (from configuration)", 161 | data={ 162 | CONF_USERNAME: username, 163 | CONF_PASSWORD: password, 164 | CONF_SPIN: spin, 165 | CONF_REGION: region, 166 | CONF_SCAN_INTERVAL: scan_interval, 167 | CONF_API_LEVEL: api_level, 168 | }, 169 | ) 170 | 171 | @staticmethod 172 | @callback 173 | def async_get_options_flow(config_entry): 174 | """Get the options flow for this handler.""" 175 | return OptionsFlowHandler(config_entry) 176 | 177 | 178 | class OptionsFlowHandler(config_entries.OptionsFlow): 179 | def __init__(self, config_entry): 180 | self._config_entry: config_entries.ConfigEntry = config_entry 181 | _LOGGER.debug( 182 | "Initializing options flow for audiconnect: %s", config_entry.title 183 | ) 184 | 185 | async def async_step_init(self, user_input=None): 186 | _LOGGER.debug( 187 | "Options flow initiated for audiconnect: %s", self._config_entry.title 188 | ) 189 | if user_input is not None: 190 | _LOGGER.debug("Received user input for options: %s", user_input) 191 | return self.async_create_entry(title="", data=user_input) 192 | 193 | current_scan_interval = self._config_entry.options.get( 194 | CONF_SCAN_INTERVAL, 195 | self._config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_UPDATE_INTERVAL), 196 | ) 197 | 198 | current_api_level = self._config_entry.options.get( 199 | CONF_API_LEVEL, 200 | self._config_entry.data.get(CONF_API_LEVEL, API_LEVELS[DEFAULT_API_LEVEL]), 201 | ) 202 | 203 | _LOGGER.debug( 204 | "Retrieved current scan interval for audiconnect %s: %s minutes", 205 | self._config_entry.title, 206 | current_scan_interval, 207 | ) 208 | 209 | _LOGGER.debug( 210 | "Preparing options form for %s with default scan interval: %s minutes, initial scan: %s, active scan: %s", 211 | self._config_entry.title, 212 | current_scan_interval, 213 | self._config_entry.options.get(CONF_SCAN_INITIAL, True), 214 | self._config_entry.options.get(CONF_SCAN_ACTIVE, True), 215 | ) 216 | 217 | return self.async_show_form( 218 | step_id="init", 219 | data_schema=vol.Schema( 220 | { 221 | vol.Required( 222 | CONF_SCAN_INITIAL, 223 | default=self._config_entry.options.get(CONF_SCAN_INITIAL, True), 224 | ): bool, 225 | vol.Required( 226 | CONF_SCAN_ACTIVE, 227 | default=self._config_entry.options.get(CONF_SCAN_ACTIVE, True), 228 | ): bool, 229 | vol.Optional( 230 | CONF_SCAN_INTERVAL, default=current_scan_interval 231 | ): vol.All(vol.Coerce(int), vol.Clamp(min=MIN_UPDATE_INTERVAL)), 232 | vol.Optional(CONF_API_LEVEL, default=current_api_level): vol.All( 233 | vol.Coerce(int), vol.In(API_LEVELS) 234 | ), 235 | } 236 | ), 237 | ) 238 | -------------------------------------------------------------------------------- /custom_components/audiconnect/device_tracker.py: -------------------------------------------------------------------------------- 1 | """Support for tracking an Audi.""" 2 | 3 | import logging 4 | from typing import Any # Import Any for type hinting if needed 5 | 6 | from homeassistant.components.device_tracker import SourceType 7 | from homeassistant.components.device_tracker.config_entry import TrackerEntity 8 | from homeassistant.config_entries import ConfigEntry # Import ConfigEntry for type hinting 9 | from homeassistant.const import CONF_USERNAME 10 | from homeassistant.core import HomeAssistant, callback 11 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback # Import AddEntitiesCallback 13 | 14 | from .const import DOMAIN, TRACKER_UPDATE 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | # async_setup_scanner is deprecated for config entry flows, can likely be removed entirely 20 | # async def async_setup_scanner(hass, config, async_see, discovery_info=None): 21 | # """Old way.""" 22 | 23 | 24 | async def async_setup_entry( 25 | hass: HomeAssistant, 26 | config_entry: ConfigEntry, 27 | async_add_entities: AddEntitiesCallback, 28 | ) -> None: # Use None return type hint for setup functions 29 | """Set up the Audi device tracker platform from a config entry.""" 30 | _LOGGER.debug("Setting up Audi device tracker for config entry: %s", config_entry.entry_id) 31 | 32 | account = config_entry.data.get(CONF_USERNAME) 33 | # Ensure the central data store exists and the account data is loaded 34 | if DOMAIN not in hass.data or account not in hass.data[DOMAIN]: 35 | _LOGGER.error("Audi Connect data not found for account %s. Ensure integration setup is complete.", account) 36 | # Returning False is deprecated, raise ConfigEntryNotReady or just log and return None 37 | return # Or raise ConfigEntryNotReady from homeassistant.exceptions 38 | 39 | audiData = hass.data[DOMAIN][account] 40 | 41 | # Prepare a list to hold the entities to be added 42 | entities_to_add = [] 43 | 44 | # Make sure config_vehicles exists and is iterable 45 | if not hasattr(audiData, 'config_vehicles') or not audiData.config_vehicles: 46 | _LOGGER.warning("No configured vehicles found for account %s during device_tracker setup.", account) 47 | # This might be normal if the user hasn't configured vehicles yet, so just return. 48 | return 49 | 50 | for config_vehicle in audiData.config_vehicles: 51 | # Make sure device_trackers exists and is iterable 52 | if not hasattr(config_vehicle, 'device_trackers') or not config_vehicle.device_trackers: 53 | _LOGGER.debug("No device trackers found for vehicle %s", getattr(config_vehicle, 'vehicle_name', 'Unknown')) 54 | continue # Skip to the next vehicle 55 | 56 | for instrument in config_vehicle.device_trackers: 57 | # Add some basic validation if possible (e.g., check required attributes) 58 | if not hasattr(instrument, 'vehicle_name') or not hasattr(instrument, 'full_name'): 59 | _LOGGER.warning("Skipping invalid instrument data during setup: %s", instrument) 60 | continue 61 | 62 | _LOGGER.debug("Creating AudiDeviceTracker for: %s", instrument.full_name) 63 | # Create the entity directly here 64 | entities_to_add.append(AudiDeviceTracker(config_entry, instrument)) # Pass config_entry if needed by entity 65 | 66 | # Add all discovered entities at once 67 | if entities_to_add: 68 | _LOGGER.info("Adding %d Audi device tracker(s)", len(entities_to_add)) 69 | # Pass update_before_add=True to fetch initial state immediately after adding 70 | async_add_entities(entities_to_add, True) 71 | else: 72 | _LOGGER.info("No Audi device trackers found to add for account %s.", account) 73 | 74 | # Setup finished successfully (even if no entities were added) 75 | # The return True is deprecated for async_setup_entry 76 | 77 | 78 | class AudiDeviceTracker(TrackerEntity): 79 | """Represent a tracked Audi device.""" 80 | 81 | # Use _attr_ convention for HA defined properties where possible 82 | _attr_icon = "mdi:car" 83 | _attr_should_poll = False # Data is pushed via dispatcher 84 | _attr_source_type = SourceType.GPS 85 | 86 | def __init__(self, config_entry: ConfigEntry, instrument: Any) -> None: # Added type hints 87 | """Initialize the Audi device tracker.""" 88 | self._instrument = instrument 89 | # Store identifiers needed for properties and updates 90 | self._vehicle_name = self._instrument.vehicle_name 91 | self._entity_name = self._instrument.name # Assuming instrument provides the specific tracker name (e.g., 'Position') 92 | self._attr_unique_id = self._instrument.full_name # Use the instrument's full_name as the unique ID 93 | 94 | # Define device information. This links the entity to a device in the registry. 95 | # The device is automatically linked to the config_entry passed implicitly by HA 96 | # when the entity is created within async_setup_entry. 97 | self._attr_device_info = { 98 | "identifiers": {(DOMAIN, self._vehicle_name)}, # Unique identifier for the device within the domain 99 | "name": self._vehicle_name, 100 | "manufacturer": "Audi", 101 | # Add model, etc., if available from the instrument or config_vehicle 102 | "model": getattr(instrument, 'vehicle_model', None), 103 | # You can optionally link the device directly to the config entry device if you have one 104 | # "via_device": (DOMAIN, config_entry.entry_id), # Uncomment if useful 105 | } 106 | 107 | # Initialize state attributes (latitude/longitude) 108 | self._latitude = None 109 | self._longitude = None 110 | self._update_state_from_instrument() # Set initial state 111 | 112 | 113 | def _update_state_from_instrument(self) -> None: 114 | """Update latitude and longitude from the instrument's state.""" 115 | state = getattr(self._instrument, 'state', None) 116 | if isinstance(state, (list, tuple)) and len(state) >= 2: 117 | try: 118 | self._latitude = float(state[0]) 119 | self._longitude = float(state[1]) 120 | except (ValueError, TypeError): 121 | _LOGGER.warning("Invalid latitude/longitude format in state for %s: %s", self.entity_id, state) 122 | self._latitude = None 123 | self._longitude = None 124 | else: 125 | # Keep previous state or set to None if state is invalid/missing 126 | # self._latitude = None # Uncomment if you want to clear location on invalid update 127 | # self._longitude = None # Uncomment if you want to clear location on invalid update 128 | _LOGGER.debug("State for %s does not contain valid lat/lon: %s", self.entity_id, state) 129 | 130 | 131 | @property 132 | def latitude(self) -> float | None: # Add type hint 133 | """Return latitude value of the device.""" 134 | return self._latitude 135 | 136 | @property 137 | def longitude(self) -> float | None: # Add type hint 138 | """Return longitude value of the device.""" 139 | return self._longitude 140 | 141 | @property 142 | def name(self) -> str: # Add type hint 143 | """Return the name of the entity.""" 144 | # Provide a clear name, combining vehicle and tracker type 145 | return f"{self._vehicle_name} {self._entity_name}" 146 | 147 | @property 148 | def extra_state_attributes(self) -> dict[str, Any] | None: # Add type hint 149 | """Return device specific state attributes.""" 150 | attrs = {} 151 | # Safely get base attributes if they exist and are a dict 152 | base_attrs = getattr(self._instrument, 'attributes', {}) 153 | if isinstance(base_attrs, dict): 154 | attrs.update(base_attrs) 155 | 156 | # Add other specific attributes, checking for existence 157 | attrs["model"] = "{}/{}".format( 158 | getattr(self._instrument, 'vehicle_model', "Unknown"), self._vehicle_name 159 | ) 160 | attrs["model_year"] = getattr(self._instrument, 'vehicle_model_year', None) 161 | attrs["model_family"] = getattr(self._instrument, 'vehicle_model_family', None) 162 | # attrs["title"] = self._vehicle_name # Often redundant with name/device name 163 | attrs["csid"] = getattr(self._instrument, 'vehicle_csid', None) 164 | attrs["vin"] = getattr(self._instrument, 'vehicle_vin', None) 165 | 166 | # Filter out None values if desired 167 | return {k: v for k, v in attrs.items() if v is not None} 168 | 169 | 170 | async def async_added_to_hass(self) -> None: 171 | """Register callbacks when entity is added.""" 172 | # Called after entity is added to hass. Register for updates. 173 | await super().async_added_to_hass() 174 | 175 | # Use async_on_remove to automatically clean up the listener 176 | # when the entity is removed. 177 | self.async_on_remove( 178 | async_dispatcher_connect( 179 | self.hass, TRACKER_UPDATE, self._async_receive_data 180 | ) 181 | ) 182 | _LOGGER.debug("%s registered for TRACKER_UPDATE signals", self.entity_id) 183 | 184 | # async_will_remove_from_hass is usually not needed if async_on_remove is used in async_added_to_hass 185 | 186 | @callback 187 | def _async_receive_data(self, instrument: Any) -> None: 188 | """Handle updated data received via dispatcher.""" 189 | # Check if the update is for this specific entity instance 190 | # Using unique_id (instrument.full_name) is more reliable than just vehicle_name 191 | if instrument.full_name != self._attr_unique_id: 192 | return 193 | 194 | _LOGGER.debug("Received update for %s", self.entity_id) 195 | self._instrument = instrument # Update the internal instrument data 196 | self._update_state_from_instrument() # Update lat/lon state 197 | 198 | # Potentially update device info if model/name can change (less common) 199 | # new_device_name = self._instrument.vehicle_name 200 | # if self._vehicle_name != new_device_name: 201 | # self._vehicle_name = new_device_name 202 | # # Update device registry (more complex, may require registry access) 203 | 204 | 205 | self.async_write_ha_state() # Schedule an update for the entity's state in HA 206 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Audi Connect Integration for Home Assistant 2 | 3 | [![GitHub Activity][commits-shield]][commits] 4 | [![License][license-shield]](LICENSE.md) 5 | [![Code Style][blackbadge]][black] 6 | 7 | [![hacs][hacsbadge]](hacs) 8 | 9 | ## Notice 10 | 11 | Due to API changes, currently not all functionality is available. Please open a issue to report the topics you are missing. 12 | 13 | ## Maintainers Wanted 14 | 15 | Always looking for more help from the community :) 16 | 17 | ## Description 18 | 19 | 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. 20 | 21 | **Note:** Certain functions require special permissions from Audi, such as position update via GPS. 22 | 23 | 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. 24 | 25 | ## Installation 26 | 27 | There are two ways this integration can be installed into [Home Assistant](https://www.home-assistant.io). 28 | 29 | The easiest and recommended way is to install the integration using [HACS](https://hacs.xyz), which makes future updates easy to track and install. 30 | 31 | Alternatively, installation can be done manually by copying the files in this repository into the `custom_components` directory in the Home Assistant configuration directory: 32 | 33 | 1. Open the configuration directory of your Home Assistant installation. 34 | 2. If you do not have a `custom_components` directory, create it. 35 | 3. In the `custom_components` directory, create a new directory called `audiconnect`. 36 | 4. Copy all files from the `custom_components/audiconnect/` directory in this repository into the `audiconnect` directory. 37 | 5. Restart Home Assistant. 38 | 6. Add the integration to Home Assistant (see **Configuration**). 39 | 40 | ## Configuration 41 | 42 | Configuration is done through the Home Assistant UI. 43 | 44 | To add the integration, go to **Settings ➤ Devices & Services ➤ Integrations**, click **➕ Add Integration**, and search for "Audi Connect". 45 | 46 | ![image](https://github.com/user-attachments/assets/68f4a38b-f09d-4486-a1a1-ab8a564095ab) 47 | 48 | ### Configuration Variables 49 | 50 | **username** 51 | 52 | - (string)(Required) The username associated with your Audi Connect account. 53 | 54 | **password** 55 | 56 | - (string)(Required) The password for your Audi Connect account. 57 | 58 | **S-PIN** 59 | 60 | - (string)(Optional) The S-PIN for your Audi Connect account. 61 | 62 | **region** 63 | 64 | - (Required) The region where your Audi Connect account is registered. 65 | - 'DE' for Europe (or leave unset) 66 | - 'US' for United States of America 67 | - 'CA' for Canada 68 | - 'CN' for China 69 | 70 | **scan_interval** 71 | 72 | - (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.) 73 | 74 | **api_level** 75 | 76 | - (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. 77 | - Level `0`: _Typically_ for gas vehicles 78 | - Level `1`: _Typically_ for e-tron (electric) vehicles. 79 | 80 | ## Options 81 | 82 | Find configuration options under **Settings ➤ Devices & Services ➤ Integrations ➤ Audi Connect ➤ Configure**: 83 | 84 | - **Cloud Update at Startup (`bool`)**: Toggle cloud updates at integration startup. Ideal for development or frequent HA restarts. 85 | - **Active Polling at Scan Interval (`bool`)**: Enable or disable active polling. 86 | - **Scan Interval (`int`)**: Defines polling frequency in minutes (minimum 15). Effective only if "Active Polling at Scan Interval" is enabled. 87 | - **API Level (`int`)**: 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. 88 | - Level `0`: _Typically_ for gas vehicles 89 | - Level `1`: _Typically_ for e-tron (electric) vehicles. 90 | 91 | _Note: The integration will reload automatically upon clicking `Submit`, but a Home Assistant restart is suggested._ 92 | 93 | ## Services 94 | 95 | ### Audi Connect: Refresh Vehicle Data 96 | 97 | `audiconnect.refresh_vehicle_data` 98 | 99 | Normal updates retrieve data from the Audi Connect cloud service, and don't interact directly with the vehicle. _This_ service triggers an update request from the vehicle itself. When data is retrieved successfully, Home Assistant is automatically updated. The service requires a vehicle identification number (VIN) as a parameter. 100 | 101 | #### Service Parameters 102 | 103 | - **`vin`**: The Vehicle Identification Number (VIN) of the Audi you want to control. 104 | 105 | ### Audi Connect: Refresh Cloud Data 106 | 107 | `audiconnect.refresh_cloud_data` 108 | 109 | _This_ service triggers an update request from the cloud. 110 | 111 | - Functionality: Updates data for all vehicles from the online source, mirroring the action performed at integration startup or during scheduled refresh intervals. 112 | - Behavior: Does not force a vehicle-side data refresh. Consequently, if vehicles haven't recently pushed updates, retrieved data might be outdated. 113 | - Note: This service replicates the function of active polling without scheduling, offering a more granular control over data refresh moments. 114 | - **IMPORTANT:** This service has no built in usage limits. Excessive use may result in a temporary suspension of your account. 115 | 116 | #### Service Parameters 117 | 118 | - `none` 119 | 120 | ### Audi Connect: Execute Vehicle Action 121 | 122 | `audiconnect.execute_vehicle_action` 123 | 124 | This service allows you to perform actions on your Audi vehicle, specified by the vehicle identification number (VIN) and the desired action. 125 | 126 | #### Service Parameters 127 | 128 | - **`vin`**: The Vehicle Identification Number (VIN) of the Audi you want to control. 129 | - **`action`**: The specific action to perform on the vehicle. Available actions include: 130 | - **`lock`**: Lock the vehicle. 131 | - **`unlock`**: Unlock the vehicle. 132 | - **`start_climatisation`**: Start the vehicle's climatisation system. (Legacy) 133 | - **`stop_climatisation`**: Stop the vehicle's climatisation system. 134 | - **`start_charger`**: Start charging the vehicle. 135 | - **`start_timed_charger`**: Start the vehicle's charger with a timer. 136 | - **`stop_charger`**: Stop charging the vehicle. 137 | - **`start_preheater`**: Start the vehicle's preheater system. 138 | - **`stop_preheater`**: Stop the vehicle's preheater system. 139 | - **`start_window_heating`**: Start heating the vehicle's windows. 140 | - **`stop_window_heating`**: Stop heating the vehicle's windows. 141 | 142 | #### Usage Example 143 | 144 | To initiate the lock action for a vehicle with VIN `WAUZZZ4G7EN123456`, use the following service call: 145 | 146 | ```yaml 147 | service: audiconnect.execute_vehicle_action 148 | data: 149 | vin: "WAUZZZ4G7EN123456" 150 | action: "lock" 151 | ``` 152 | 153 | #### Notes 154 | 155 | - Certain action require the S-PIN to be set in the configuration. 156 | - When the action is successfully performed, an update request is automatically triggered. 157 | 158 | ### Audi Connect: Start Climate Control 159 | 160 | `audiconnect.start_climate_control` 161 | 162 | This service allows you to start the climate control with options for temperature, glass surface heating, and auto seat comfort. 163 | 164 | #### Service Parameters 165 | 166 | - **`vin`**: The Vehicle Identification Number (VIN) of the Audi you want to control. 167 | - **`temp_f`** (_optional_): Desired temperature in Fahrenheit. Default is `70`. 168 | - **`temp_c`** (_optional_): Desired temperature in Celsius. Default is `21`. 169 | - **`glass_heating`** (_optional_): Enable (`True`) or disable (`False`) glass heating. Default is `False`. 170 | - **`seat_fl`** (_optional_): Enable (`True`) or disable (`False`) the front-left seat heater. Default is `False`. 171 | - **`seat_fr`** (_optional_): Enable (`True`) or disable (`False`) the front-right seat heater. Default is `False`. 172 | - **`seat_rl`** (_optional_): Enable (`True`) or disable (`False`) the rear-left seat heater. Default is `False`. 173 | - **`seat_rr`** (_optional_): Enable (`True`) or disable (`False`) the rear-right seat heater. Default is `False`. 174 | 175 | #### Usage Example 176 | 177 | 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: 178 | 179 | ```yaml 180 | service: audiconnect.start_climate_control 181 | data: 182 | vin: "WAUZZZ4G7EN123456" 183 | temp_f: 72 184 | glass_heating: True 185 | seat_fl: True 186 | seat_fr: True 187 | ``` 188 | 189 | #### Notes 190 | 191 | - The `temp_f` and `temp_c` parameters are mutually exclusive. If both are provided, `temp_f` takes precedence. 192 | - If neither `temp_f` nor `temp_c` is provided, the system defaults to 70°F or 21°C. 193 | - Certain action require the S-PIN to be set in the configuration. 194 | - When the action is successfully performed, an update request is automatically triggered. 195 | 196 | ## Example Dashboard Card 197 | 198 | Below is an example Dashboard (Lovelace) card illustrating some of the sensors this Home Assistant addon provides. 199 | 200 | ![Example Dashboard Card](card_example.png) 201 | 202 | The card requires the following front end mods: 203 | 204 | - https://github.com/thomasloven/lovelace-card-mod 205 | - https://github.com/custom-cards/circle-sensor-card 206 | 207 | These mods can (like this integration) be installed using HACS. 208 | 209 | The card uses the following code in `ui-lovelace.yaml` (or wherever your Dashboard is configured). 210 | 211 | ```yaml 212 | - type: picture-elements 213 | image: /local/pictures/audi_sq7.jpeg 214 | style: | 215 | ha-card { 216 | border-radius: 10px; 217 | border: solid 1px rgba(100,100,100,0.3); 218 | box-shadow: 3px 3px rgba(0,0,0,0.4); 219 | overflow: hidden; 220 | } 221 | elements: 222 | - type: image 223 | image: /local/pictures/cardbackK.png 224 | style: 225 | left: 50% 226 | top: 90% 227 | width: 100% 228 | height: 60px 229 | 230 | - type: icon 231 | icon: mdi:car-door 232 | entity: sensor.doors_trunk_sq7 233 | tap_action: more_info 234 | style: {color: white, left: 10%, top: 86%} 235 | - type: state-label 236 | entity: sensor.doors_trunk_sq7 237 | style: {color: white, left: 10%, top: 95%} 238 | 239 | - type: state-icon 240 | entity: sensor.windows_sq7 241 | tap_action: more_info 242 | style: {color: white, left: 30%, top: 86%} 243 | - type: state-label 244 | entity: sensor.windows_sq7 245 | style: {color: white, left: 30%, top: 95%} 246 | 247 | - type: icon 248 | icon: mdi:oil 249 | entity: sensor.audi_sq7_oil_level 250 | tap_action: more_info 251 | style: {color: white, left: 50%, top: 86%} 252 | - type: state-label 253 | entity: sensor.audi_sq7_oil_level 254 | style: {color: white, left: 50%, top: 95%} 255 | 256 | - type: icon 257 | icon: mdi:room-service-outline 258 | entity: sensor.audi_sq7_service_inspection_time 259 | tap_action: more_info 260 | style: {color: white, left: 70%, top: 86%} 261 | - type: state-label 262 | entity: sensor.audi_sq7_service_inspection_time 263 | style: {color: white, left: 70%, top: 95%} 264 | 265 | - type: icon 266 | icon: mdi:speedometer 267 | entity: sensor.audi_sq7_mileage 268 | tap_action: more_info 269 | style: {color: white, left: 90%, top: 86%} 270 | - type: state-label 271 | entity: sensor.audi_sq7_mileage 272 | style: {color: white, left: 90%, top: 95%} 273 | 274 | - type: custom:circle-sensor-card 275 | entity: sensor.audi_sq7_tank_level 276 | max: 100 277 | min: 0 278 | stroke_width: 15 279 | gradient: true 280 | fill: '#aaaaaabb' 281 | name: tank 282 | units: ' ' 283 | font_style: 284 | font-size: 1.0em 285 | font-color: white 286 | text-shadow: '1px 1px black' 287 | style: 288 | top: 5% 289 | left: 80% 290 | width: 4em 291 | height: 4em 292 | transform: none 293 | 294 | - type: custom:circle-sensor-card 295 | entity: sensor.audi_sq7_range 296 | max: 630 297 | min: 0 298 | stroke_width: 15 299 | gradient: true 300 | fill: '#aaaaaabb' 301 | name: range 302 | units: ' ' 303 | font_style: 304 | font-size: 1.0em 305 | font-color: white 306 | text-shadow: '1px 1px black' 307 | style: 308 | top: 5% 309 | left: 5% 310 | width: 4em 311 | height: 4em 312 | transform: none 313 | ``` 314 | 315 | [commits-shield]: https://img.shields.io/github/commit-activity/y/audiconnect/audi_connect_ha?style=for-the-badge 316 | [commits]: https://github.com/audiconnect/audi_connect_ha/commits/master 317 | [hacs]: https://github.com/custom-components/hacs 318 | [hacsbadge]: https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge 319 | [license-shield]: https://img.shields.io/github/license/audiconnect/audi_connect_ha?style=for-the-badge 320 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Arjen%20van%20Rhijn%20%40arjenvrh-blue.svg?style=for-the-badge 321 | [blackbadge]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 322 | [black]: https://github.com/ambv/black 323 | -------------------------------------------------------------------------------- /custom_components/audiconnect/audi_account.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import copy 4 | 5 | import voluptuous as vol 6 | 7 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform 8 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 9 | import homeassistant.helpers.config_validation as cv 10 | from homeassistant.helpers.dispatcher import async_dispatcher_send 11 | from homeassistant.util.dt import utcnow 12 | 13 | from .audi_connect_account import AudiConnectAccount, AudiConnectObserver 14 | from .audi_models import VehicleData 15 | from .const import ( 16 | COMPONENTS, 17 | CONF_ACTION, 18 | CONF_CLIMATE_GLASS, 19 | CONF_CLIMATE_SEAT_FL, 20 | CONF_CLIMATE_SEAT_FR, 21 | CONF_CLIMATE_SEAT_RL, 22 | CONF_CLIMATE_SEAT_RR, 23 | CONF_CLIMATE_TEMP_C, 24 | CONF_CLIMATE_TEMP_F, 25 | CONF_REGION, 26 | CONF_SPIN, 27 | CONF_VIN, 28 | DOMAIN, 29 | CLIMATE_UPDATE, 30 | SIGNAL_STATE_UPDATED, 31 | TRACKER_UPDATE, 32 | UPDATE_SLEEP, 33 | CONF_API_LEVEL, 34 | DEFAULT_API_LEVEL, 35 | API_LEVELS, 36 | ) 37 | from .dashboard import Dashboard 38 | 39 | REFRESH_VEHICLE_DATA_FAILED_EVENT = "refresh_failed" 40 | REFRESH_VEHICLE_DATA_COMPLETED_EVENT = "refresh_completed" 41 | 42 | SERVICE_REFRESH_VEHICLE_DATA = "refresh_vehicle_data" 43 | SERVICE_REFRESH_VEHICLE_DATA_SCHEMA = vol.Schema( 44 | { 45 | vol.Required(CONF_VIN): cv.string, 46 | } 47 | ) 48 | 49 | SERVICE_EXECUTE_VEHICLE_ACTION = "execute_vehicle_action" 50 | SERVICE_EXECUTE_VEHICLE_ACTION_SCHEMA = vol.Schema( 51 | {vol.Required(CONF_VIN): cv.string, vol.Required(CONF_ACTION): cv.string} 52 | ) 53 | 54 | SERVICE_START_CLIMATE_CONTROL = "start_climate_control" 55 | SERVICE_START_CLIMATE_CONTROL_SCHEMA = vol.Schema( 56 | { 57 | vol.Required(CONF_VIN): cv.string, 58 | vol.Optional(CONF_CLIMATE_TEMP_F): cv.positive_int, 59 | vol.Optional(CONF_CLIMATE_TEMP_C): cv.positive_int, 60 | vol.Optional(CONF_CLIMATE_GLASS): cv.boolean, 61 | vol.Optional(CONF_CLIMATE_SEAT_FL): cv.boolean, 62 | vol.Optional(CONF_CLIMATE_SEAT_FR): cv.boolean, 63 | vol.Optional(CONF_CLIMATE_SEAT_RL): cv.boolean, 64 | vol.Optional(CONF_CLIMATE_SEAT_RR): cv.boolean, 65 | } 66 | ) 67 | 68 | SERVICE_STOP_CLIMATE_CONTROL = "stop_climate_control" 69 | SERVICE_STOP_CLIMATE_CONTROL_SCHEMA = vol.Schema( 70 | { 71 | vol.Required(CONF_VIN): cv.string, 72 | } 73 | ) 74 | 75 | PLATFORMS: list[str] = [ 76 | Platform.BINARY_SENSOR, 77 | Platform.SENSOR, 78 | Platform.DEVICE_TRACKER, 79 | Platform.LOCK, 80 | Platform.SWITCH, 81 | Platform.CLIMATE, 82 | ] 83 | 84 | SERVICE_REFRESH_CLOUD_DATA = "refresh_cloud_data" 85 | 86 | _LOGGER = logging.getLogger(__name__) 87 | 88 | 89 | class AudiAccount(AudiConnectObserver): 90 | def __init__(self, hass, config_entry): 91 | """Initialize the component state.""" 92 | self.hass = hass 93 | self.config_entry = config_entry 94 | self.config_vehicles = set() 95 | self.vehicles = set() 96 | 97 | def init_connection(self): 98 | session = async_get_clientsession(self.hass) 99 | self.connection = AudiConnectAccount( 100 | session=session, 101 | username=self.config_entry.data.get(CONF_USERNAME), 102 | password=self.config_entry.data.get(CONF_PASSWORD), 103 | country=self.config_entry.data.get(CONF_REGION), 104 | spin=self.config_entry.data.get(CONF_SPIN), 105 | api_level=self.config_entry.options.get( 106 | CONF_API_LEVEL, 107 | self.config_entry.data.get( 108 | CONF_API_LEVEL, API_LEVELS[DEFAULT_API_LEVEL] 109 | ), 110 | ), 111 | ) 112 | 113 | self.hass.services.async_register( 114 | DOMAIN, 115 | SERVICE_REFRESH_VEHICLE_DATA, 116 | self.refresh_vehicle_data, 117 | schema=SERVICE_REFRESH_VEHICLE_DATA_SCHEMA, 118 | ) 119 | self.hass.services.async_register( 120 | DOMAIN, 121 | SERVICE_EXECUTE_VEHICLE_ACTION, 122 | self.execute_vehicle_action, 123 | schema=SERVICE_EXECUTE_VEHICLE_ACTION_SCHEMA, 124 | ) 125 | self.hass.services.async_register( 126 | DOMAIN, 127 | SERVICE_REFRESH_CLOUD_DATA, 128 | self.update, 129 | ) 130 | 131 | self.connection.add_observer(self) 132 | 133 | def is_enabled(self, attr): 134 | return True 135 | # """Return true if the user has enabled the resource.""" 136 | # return attr in config[DOMAIN].get(CONF_RESOURCES, [attr]) 137 | 138 | async def discover_vehicles(self, vehicles): 139 | if len(vehicles) > 0: 140 | for vehicle in vehicles: 141 | vin = vehicle.vin.lower() 142 | 143 | self.vehicles.add(vin) 144 | 145 | cfg_vehicle = VehicleData(self.config_entry) 146 | cfg_vehicle.vehicle = vehicle 147 | self.config_vehicles.add(cfg_vehicle) 148 | 149 | dashboard = Dashboard(self.connection, vehicle) 150 | 151 | for instrument in ( 152 | instrument 153 | for instrument in dashboard.instruments 154 | if instrument._component in COMPONENTS 155 | and self.is_enabled(instrument.slug_attr) 156 | ): 157 | _LOGGER.debug( 158 | "Processing Instrument: Name=%s, Component=%s, Slug=%s, Enabled=%s", 159 | instrument.name, 160 | instrument._component, 161 | instrument.slug_attr, 162 | self.is_enabled(instrument.slug_attr), 163 | ) 164 | if instrument._component == "sensor": 165 | cfg_vehicle.sensors.add(instrument) 166 | if instrument._component == "binary_sensor": 167 | cfg_vehicle.binary_sensors.add(instrument) 168 | if instrument._component == "switch": 169 | cfg_vehicle.switches.add(instrument) 170 | if instrument._component == "device_tracker": 171 | cfg_vehicle.device_trackers.add(instrument) 172 | if instrument._component == "lock": 173 | cfg_vehicle.locks.add(instrument) 174 | if instrument._component == "climate": 175 | cfg_vehicle.climates.add(instrument) 176 | # ADD THIS LOGGING: 177 | _LOGGER.info( 178 | "Found CLIMATE instrument for VIN %s: %s (Added to cfg_vehicle.climates)", 179 | vehicle.vin, 180 | instrument.full_name, 181 | ) 182 | else: 183 | # Optional: Log if component not matched 184 | _LOGGER.debug("Instrument component '%s' not specifically handled for %s", 185 | instrument._component, vehicle.vin) 186 | await self.hass.config_entries.async_forward_entry_setups( 187 | self.config_entry, PLATFORMS 188 | ) 189 | 190 | async def update(self, now): 191 | """Update status from the cloud.""" 192 | _LOGGER.debug("Starting refresh cloud data...") 193 | if not await self.connection.update(None): 194 | _LOGGER.warning("Failed refresh cloud data") 195 | return False 196 | 197 | # Discover new vehicles that have not been added yet 198 | new_vehicles = [ 199 | x for x in self.connection._vehicles if x.vin not in self.vehicles 200 | ] 201 | if new_vehicles: 202 | _LOGGER.debug("Retrieved %d vehicle(s)", len(new_vehicles)) 203 | await self.discover_vehicles(new_vehicles) 204 | 205 | async_dispatcher_send(self.hass, SIGNAL_STATE_UPDATED) 206 | 207 | for config_vehicle in self.config_vehicles: 208 | for instrument in config_vehicle.device_trackers: 209 | async_dispatcher_send(self.hass, TRACKER_UPDATE, instrument) 210 | for instrument in config_vehicle.climates: 211 | async_dispatcher_send(self.hass, CLIMATE_UPDATE, instrument) 212 | 213 | 214 | _LOGGER.debug("Successfully refreshed cloud data") 215 | return True 216 | 217 | async def execute_vehicle_action(self, service): 218 | vin = service.data.get(CONF_VIN).lower() 219 | action = service.data.get(CONF_ACTION).lower() 220 | 221 | if action == "lock": 222 | await self.connection.set_vehicle_lock(vin, True) 223 | if action == "unlock": 224 | await self.connection.set_vehicle_lock(vin, False) 225 | if action == "start_climatisation": 226 | await self.connection.set_vehicle_climatisation(vin, True) 227 | if action == "stop_climatisation": 228 | await self.connection.set_vehicle_climatisation(vin, False) 229 | if action == "start_charger": 230 | await self.connection.set_battery_charger(vin, True, False) 231 | if action == "start_timed_charger": 232 | await self.connection.set_battery_charger(vin, True, True) 233 | if action == "stop_charger": 234 | await self.connection.set_battery_charger(vin, False, False) 235 | if action == "start_preheater": 236 | await self.connection.set_vehicle_pre_heater(vin, True) 237 | if action == "stop_preheater": 238 | await self.connection.set_vehicle_pre_heater(vin, False) 239 | if action == "start_window_heating": 240 | await self.connection.set_vehicle_window_heating(vin, True) 241 | if action == "stop_window_heating": 242 | await self.connection.set_vehicle_window_heating(vin, False) 243 | 244 | async def async_start_climate(self, vin: str, temp_f=None, temp_c=None, glass_heating=False, seat_fl=False, seat_fr=False, seat_rl=False, seat_rr=False, **kwargs): 245 | """Start climate control via the underlying connection.""" 246 | _LOGGER.debug("Initiating Start Climate Control Service...") 247 | 248 | await self.connection.start_climate_control( 249 | vin=vin.lower(), # Pass VIN explicitly 250 | temp_f=temp_f, 251 | temp_c=temp_c, 252 | glass_heating=glass_heating, 253 | seat_fl=seat_fl, 254 | seat_fr=seat_fr, 255 | seat_rl=seat_rl, 256 | seat_rr=seat_rr, 257 | ) 258 | 259 | async def async_stop_climate(self, vin: str, **kwargs): 260 | """Stop climate control via the underlying connection.""" 261 | _LOGGER.debug("Initiating Stop Climate Control Service...") 262 | vin = service.data.get(CONF_VIN).lower() 263 | 264 | await self.connection.stop_climate_control(vin=vin.lower()) 265 | 266 | async def async_set_climate_temp(self, vin: str, temperature: float) -> None: 267 | """Set the target climate temperature using the GET/PUT settings endpoint.""" 268 | _LOGGER.debug("Setting climate temperature for VIN %s to %.1f C via settings endpoint", vin, temperature) 269 | vin_upper = vin.upper() # Ensure VIN is uppercase for API calls 270 | 271 | try: 272 | # 1. Get current full status to extract some settings 273 | # This response has the nested structure. 274 | current_status_response = await self.connection.async_get_climate_settings( 275 | vin_upper 276 | ) 277 | if not current_status_response: 278 | _LOGGER.error( 279 | "Failed to get current climate status/settings for VIN %s. Cannot set temperature.", 280 | vin, 281 | ) 282 | return 283 | 284 | # Safely extract the current settings part from the nested structure 285 | current_settings_value = ( 286 | current_status_response.get("climatisation", {}) 287 | .get("climatisationSettings", {}) 288 | .get("value", {}) 289 | ) 290 | 291 | if not current_settings_value: 292 | _LOGGER.warning( 293 | "Could not find current 'climatisationSettings.value' in response for VIN %s. Proceeding with defaults.", 294 | vin 295 | ) 296 | # Define defaults if current settings aren't found, might be risky 297 | current_settings_value = { 298 | "climatizationAtUnlock": False, 299 | "windowHeatingEnabled": True, # Default based on example? Verify! 300 | "zoneFrontLeftEnabled": False, # Default? Verify! 301 | "zoneFrontRightEnabled": False, # Default? Verify! 302 | } 303 | 304 | 305 | # 2. Modify the temperature 306 | # Make a copy to avoid modifying the original potentially cached dict 307 | updated_settings = { 308 | "targetTemperature": float(temperature), # Use the requested temperature (already in C) 309 | "targetTemperatureUnit": "celsius", 310 | "climatizationAtUnlock": current_settings_value.get( 311 | "climatizationAtUnlock", False # Default if missing 312 | ), 313 | "windowHeatingEnabled": current_settings_value.get( 314 | "windowHeatingEnabled", True # Default if missing (based on example) 315 | ), 316 | "zoneFrontLeftEnabled": current_settings_value.get( 317 | "zoneFrontLeftEnabled", False # Default if missing 318 | ), 319 | "zoneFrontRightEnabled": current_settings_value.get( 320 | "zoneFrontRightEnabled", False # Default if missing 321 | ), 322 | "climatisationWithoutExternalPower": True, 323 | # Add zoneRearLeftEnabled / zoneRearRightEnabled if the PUT API expects them 324 | #"zoneRearLeftEnabled": current_settings_value.get("zoneRearLeftEnabled", False), 325 | #"zoneRearRightEnabled": current_settings_value.get("zoneRearRightEnabled", False), 326 | } 327 | 328 | _LOGGER.debug("Updated settings object for PUT: %s", updated_settings) 329 | 330 | # 3. PUT the updated settings 331 | success = await self.connection.async_set_climate_settings(vin_upper, updated_settings) 332 | 333 | if success: 334 | _LOGGER.info("Climate temperature settings update request sent successfully for VIN %s", vin) 335 | # Trigger a refresh shortly after to see the change reflected 336 | self.hass.async_create_task(self._refresh_after_action(vin)) 337 | else: 338 | _LOGGER.error("Failed to PUT updated climate settings for VIN %s", vin) 339 | 340 | except Exception as e: 341 | _LOGGER.error("Error setting climate temperature via settings endpoint for VIN %s: %s", vin, e, exc_info=True) 342 | 343 | async def async_set_window_heating(self, vin: str, enable: bool) -> bool: 344 | """Enable or disable window heating via the PUT settings endpoint.""" 345 | action = "Enable" if enable else "Disable" 346 | _LOGGER.debug( 347 | "%s window heating for VIN %s via settings endpoint", 348 | action, 349 | vin 350 | ) 351 | vin_upper = vin.upper() 352 | 353 | try: 354 | # 1. Get current full status to extract settings 355 | current_status_response = await self.connection.async_get_climate_settings(vin_upper) 356 | if not current_status_response: 357 | _LOGGER.error("Failed to get current climate settings for VIN %s. Cannot set window heating.", vin) 358 | return False 359 | 360 | current_settings_value = ( 361 | current_status_response.get("climatisation", {}) 362 | .get("climatisationSettings", {}) 363 | .get("value", {}) 364 | ) 365 | if not current_settings_value: 366 | _LOGGER.error("Could not find current 'climatisationSettings.value' in response for VIN %s. Cannot set window heating.", vin) 367 | return False # Cannot proceed without knowing other settings 368 | 369 | # 2. Construct the NEW settings payload required by the PUT endpoint 370 | updated_settings_payload = { 371 | "targetTemperature": current_settings_value.get('targetTemperature_C', 21), # Keep current temp (use _C version) 372 | "targetTemperatureUnit": "celsius", 373 | "climatizationAtUnlock": current_settings_value.get("climatizationAtUnlock", False), 374 | "windowHeatingEnabled": bool(enable), # <<< Set the desired state 375 | "zoneFrontLeftEnabled": current_settings_value.get("zoneFrontLeftEnabled", False), 376 | "zoneFrontRightEnabled": current_settings_value.get("zoneFrontRightEnabled", False), 377 | "climatisationWithoutExternalPower": True, # Assuming this is required/default 378 | } 379 | _LOGGER.debug("Constructed settings payload for PUT (Window Heat): %s", updated_settings_payload) 380 | 381 | # 3. PUT the specifically constructed payload 382 | success = await self.connection.async_set_climate_settings(vin_upper, updated_settings_payload) 383 | 384 | if success: 385 | _LOGGER.info("Window heating state update request sent successfully for VIN %s", vin) 386 | self.hass.async_create_task(self._refresh_after_action(vin)) 387 | return success 388 | 389 | except Exception as e: 390 | _LOGGER.error("Error setting window heating for VIN %s: %s", vin, e, exc_info=True) 391 | return False 392 | 393 | 394 | async def handle_notification(self, vin: str, action: str) -> None: 395 | await self._refresh_vehicle_data(vin) 396 | 397 | async def refresh_vehicle_data(self, service): 398 | vin = service.data.get(CONF_VIN).lower() 399 | await self._refresh_vehicle_data(vin) 400 | 401 | async def _refresh_vehicle_data(self, vin): 402 | redacted_vin = "*" * (len(vin) - 4) + vin[-4:] 403 | res = await self.connection.refresh_vehicle_data(vin) 404 | 405 | if res is True: 406 | _LOGGER.debug("Refresh vehicle data successful for VIN: %s", redacted_vin) 407 | self.hass.bus.fire( 408 | "{}_{}".format(DOMAIN, REFRESH_VEHICLE_DATA_COMPLETED_EVENT), 409 | {"vin": redacted_vin}, 410 | ) 411 | elif res == "disabled": 412 | _LOGGER.debug("Refresh vehicle data is disabled for VIN: %s", redacted_vin) 413 | else: 414 | _LOGGER.debug("Refresh vehicle data failed for VIN: %s", redacted_vin) 415 | self.hass.bus.fire( 416 | "{}_{}".format(DOMAIN, REFRESH_VEHICLE_DATA_FAILED_EVENT), 417 | {"vin": redacted_vin}, 418 | ) 419 | 420 | _LOGGER.debug("Requesting to refresh cloud data in %d seconds...", UPDATE_SLEEP) 421 | await asyncio.sleep(UPDATE_SLEEP) 422 | 423 | try: 424 | _LOGGER.debug("Requesting to refresh cloud data now...") 425 | await self.update(utcnow()) 426 | except Exception as e: 427 | _LOGGER.exception("Refresh cloud data failed: %s", str(e)) 428 | 429 | # --- Helper method to trigger refresh after an action --- 430 | async def _refresh_after_action(self, vin: str, delay: int = UPDATE_SLEEP): 431 | """Schedule a refresh after performing an action.""" 432 | _LOGGER.debug("Scheduling cloud data refresh in %d seconds after action for VIN %s", delay, vin) 433 | await asyncio.sleep(delay) 434 | try: 435 | _LOGGER.debug("Requesting post-action cloud data refresh now...") 436 | await self.update(utcnow()) 437 | except Exception as e: 438 | _LOGGER.exception("Post-action refresh cloud data failed: %s", str(e)) -------------------------------------------------------------------------------- /custom_components/audiconnect/climate.py: -------------------------------------------------------------------------------- 1 | # climate.py 2 | """Support for Audi Climate Control.""" 3 | 4 | import logging 5 | from typing import Any, List, Optional # Use List and Optional for type hinting 6 | 7 | import voluptuous as vol 8 | 9 | from homeassistant.components.climate import ( 10 | ClimateEntity, 11 | ClimateEntityFeature, 12 | HVACMode, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.const import ( 16 | ATTR_TEMPERATURE, 17 | CONF_USERNAME, 18 | UnitOfTemperature, # Use UnitOfTemperature 19 | ) 20 | from homeassistant.core import HomeAssistant, ServiceCall, callback # Import ServiceCall 21 | from homeassistant.helpers import config_validation as cv 22 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 23 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 24 | 25 | from .const import ( # Assuming these are defined in your const.py 26 | CLIMATE_UPDATE, # Need a dispatcher signal for climate updates 27 | CONF_VIN, 28 | CONF_CLIMATE_GLASS, 29 | CONF_CLIMATE_SEAT_FL, 30 | CONF_CLIMATE_SEAT_FR, 31 | CONF_CLIMATE_SEAT_RL, 32 | CONF_CLIMATE_SEAT_RR, 33 | CONF_CLIMATE_TEMP_C, 34 | CONF_CLIMATE_TEMP_F, 35 | DOMAIN, 36 | ) 37 | 38 | 39 | # --- Service Definitions needed here for registration --- 40 | SERVICE_START_CLIMATE_CONTROL = "start_climate_control" 41 | SERVICE_STOP_CLIMATE_CONTROL = "stop_climate_control" 42 | # Schemas will be defined within async_setup_entry 43 | 44 | 45 | _LOGGER = logging.getLogger(__name__) 46 | 47 | 48 | async def async_setup_entry( 49 | hass: HomeAssistant, 50 | config_entry: ConfigEntry, 51 | async_add_entities: AddEntitiesCallback, 52 | ) -> None: 53 | """Set up the Audi climate platform from a config entry.""" 54 | _LOGGER.debug("Setting up Audi climate for config entry: %s", config_entry.entry_id) 55 | 56 | account = config_entry.data.get(CONF_USERNAME) 57 | # Ensure the central data store exists and the account data is loaded 58 | if DOMAIN not in hass.data or account not in hass.data[DOMAIN]: 59 | _LOGGER.error("Audi Connect data not found for account %s. Ensure integration setup is complete.", account) 60 | return # Or raise ConfigEntryNotReady 61 | 62 | audiData = hass.data[DOMAIN][account] 63 | entities_to_add = [] 64 | 65 | # Check for configured vehicles 66 | if not hasattr(audiData, 'config_vehicles') or not audiData.config_vehicles: 67 | _LOGGER.warning("No configured vehicles found for account %s during climate setup.", account) 68 | return 69 | 70 | for config_vehicle in audiData.config_vehicles: 71 | # Check for climate instruments (assuming the list is named 'climates') 72 | # *** ADJUST 'climates' if your data structure uses a different name *** 73 | if not hasattr(config_vehicle, 'climates') or not config_vehicle.climates: 74 | _LOGGER.debug("No climate controls found for vehicle %s", getattr(config_vehicle, 'vehicle_name', 'Unknown')) 75 | continue # Skip to the next vehicle 76 | 77 | for instrument in config_vehicle.climates: 78 | # Add basic validation 79 | if not hasattr(instrument, 'vehicle_name') or not hasattr(instrument, 'full_name'): 80 | _LOGGER.warning("Skipping invalid climate instrument data during setup: %s", instrument) 81 | continue 82 | 83 | _LOGGER.debug("Creating AudiClimate for: %s", instrument.full_name) 84 | entities_to_add.append(AudiClimate(config_entry, instrument, audiData)) # Pass audiData for service calls 85 | 86 | # Add all discovered entities at once 87 | if entities_to_add: 88 | _LOGGER.info("Adding %d Audi climate control(s)", len(entities_to_add)) 89 | async_add_entities(entities_to_add, True) # update_before_add=True 90 | else: 91 | _LOGGER.info("No Audi climate controls found to add for account %s.", account) 92 | 93 | # --- Service Handlers defined within setup_entry's scope --- 94 | async def async_handle_start_climate(service: ServiceCall) -> None: 95 | """Handle the service call to start climate control.""" 96 | vin = service.data[CONF_VIN] 97 | temp_f = service.data.get(CONF_CLIMATE_TEMP_F) 98 | temp_c = service.data.get(CONF_CLIMATE_TEMP_C) 99 | glass_heating = service.data.get(CONF_CLIMATE_GLASS) 100 | seat_fl = service.data.get(CONF_CLIMATE_SEAT_FL) 101 | seat_fr = service.data.get(CONF_CLIMATE_SEAT_FR) 102 | seat_rl = service.data.get(CONF_CLIMATE_SEAT_RL) 103 | seat_rr = service.data.get(CONF_CLIMATE_SEAT_RR) 104 | 105 | _LOGGER.debug("Service %s called for VIN: %s", SERVICE_START_CLIMATE_CONTROL, vin) 106 | 107 | # Call the NEW method on the audiData (AudiAccount) object 108 | if hasattr(audiData, 'async_start_climate'): 109 | try: 110 | await audiData.async_start_climate( 111 | vin=vin, 112 | temp_c=temp_c, 113 | temp_f=temp_f, 114 | glass_heating=glass_heating, 115 | seat_fl=seat_fl, 116 | seat_fr=seat_fr, 117 | seat_rl=seat_rl, 118 | seat_rr=seat_rr, 119 | ) 120 | _LOGGER.info("Climate start requested via service for VIN %s", vin) 121 | except Exception as e: 122 | _LOGGER.error("Error calling async_start_climate for VIN %s from service: %s", vin, e) 123 | else: 124 | _LOGGER.error("Audi data object does not have 'async_start_climate' method for service call.") 125 | 126 | async def async_handle_stop_climate(service: ServiceCall) -> None: 127 | """Handle the service call to stop climate control.""" 128 | vin = service.data[CONF_VIN] 129 | _LOGGER.debug("Service %s called for VIN: %s", SERVICE_STOP_CLIMATE_CONTROL, vin) 130 | 131 | # Call the NEW method on the audiData (AudiAccount) object 132 | if hasattr(audiData, 'async_stop_climate'): 133 | try: 134 | await audiData.async_stop_climate(vin=vin) 135 | _LOGGER.info("Climate stop requested via service for VIN %s", vin) 136 | except Exception as e: 137 | _LOGGER.error("Error calling async_stop_climate for VIN %s from service: %s", vin, e) 138 | else: 139 | _LOGGER.error("Audi data object does not have 'async_stop_climate' method for service call.") 140 | 141 | # --- Register the services here --- 142 | hass.services.async_register( 143 | DOMAIN, 144 | SERVICE_START_CLIMATE_CONTROL, 145 | async_handle_start_climate, 146 | schema=SERVICE_START_CLIMATE_CONTROL_SCHEMA, 147 | ) 148 | 149 | hass.services.async_register( 150 | DOMAIN, 151 | SERVICE_STOP_CLIMATE_CONTROL, 152 | async_handle_stop_climate, 153 | schema=SERVICE_STOP_CLIMATE_CONTROL_SCHEMA, 154 | ) 155 | # --- End Service Registration --- 156 | 157 | 158 | 159 | class AudiClimate(ClimateEntity): 160 | """Representation of an Audi Climate system.""" 161 | 162 | # Use _attr_ convention for HA defined properties 163 | _attr_hvac_modes: List[HVACMode] = [HVACMode.HEAT_COOL, HVACMode.OFF] # Basic modes for pre-climatization 164 | _attr_supported_features: ClimateEntityFeature = ( 165 | ClimateEntityFeature.TARGET_TEMPERATURE | 166 | ClimateEntityFeature.TURN_ON | 167 | ClimateEntityFeature.TURN_OFF 168 | # Add PRESET_MODE if you implement seat/window heating as presets 169 | # Add FAN_MODE if fan control is available 170 | ) 171 | _attr_temperature_unit: str = UnitOfTemperature.CELSIUS # Report in Celsius, HA handles display conversion 172 | _attr_target_temperature_step: float = 0.5 # Or 1.0 depending on car API 173 | _attr_min_temp: float = 16.0 # Set realistic min/max if available from API 174 | _attr_max_temp: float = 30.0 175 | _attr_should_poll: bool = False # Data is pushed via dispatcher 176 | _attr_has_entity_name = True # Use "Climate" as the entity name suffix 177 | 178 | def __init__(self, config_entry: ConfigEntry, instrument: Any, audi_data: Any) -> None: 179 | """Initialize the Audi climate device.""" 180 | self._instrument = instrument 181 | self._audi_data = audi_data # Store reference to call API methods if needed directly 182 | self._config_entry_id = config_entry.entry_id # Store for potential future use 183 | 184 | # Store identifiers needed for properties and updates 185 | self._vehicle_name = self._instrument.vehicle_name 186 | self._attr_unique_id = self._instrument.full_name # Instrument's full_name as unique ID 187 | 188 | # Device Information 189 | self._attr_device_info = { 190 | "identifiers": {(DOMAIN, self._vehicle_name)}, 191 | "name": self._vehicle_name, 192 | "manufacturer": "Audi", 193 | "model": getattr(instrument, 'vehicle_model', None), 194 | "via_device": (DOMAIN, config_entry.entry_id), # Link to the integration's main device entry 195 | } 196 | 197 | # Initial state update 198 | self._update_state_from_instrument() 199 | 200 | def _update_state_from_instrument(self) -> None: 201 | """Update the entity's state based on the instrument data.""" 202 | _LOGGER.debug("Updating state for %s from instrument: %s", self.entity_id, self._instrument) # Log the instrument 203 | 204 | # --- HVAC Mode (On/Off) --- 205 | # Get state from instrument's 'state' property 206 | instrument_state_raw = getattr(self._instrument, 'state', None) 207 | _LOGGER.debug("Raw instrument state: %s", instrument_state_raw) 208 | 209 | # Determine HVAC mode based on the raw state string 210 | if isinstance(instrument_state_raw, str): 211 | state_lower = instrument_state_raw.lower() 212 | if state_lower == 'off': 213 | self._attr_hvac_mode = HVACMode.OFF 214 | elif state_lower in ['on', 'heating', 'cooling', 'active']: # Add expected 'on' states 215 | self._attr_hvac_mode = HVACMode.HEAT_COOL # Or specific HEAT/COOL if distinguishable 216 | else: 217 | _LOGGER.warning("Unknown climatisationState '%s' for %s", instrument_state_raw, self.entity_id) 218 | self._attr_hvac_mode = None # Or HVACMode.OFF as default? 219 | else: 220 | _LOGGER.warning("Invalid or missing climatisationState type (%s) for %s", type(instrument_state_raw), self.entity_id) 221 | self._attr_hvac_mode = None # Or HVACMode.OFF 222 | 223 | # --- Target Temperature --- 224 | # Get temp from instrument's 'target_temperature' property 225 | target_temp_raw = getattr(self._instrument, 'target_temperature', None) 226 | _LOGGER.debug("Raw instrument target_temperature: %s", target_temp_raw) 227 | try: 228 | # Instrument property should already return float in Celsius 229 | self._attr_target_temperature = float(target_temp_raw) if target_temp_raw is not None else None 230 | except (ValueError, TypeError): 231 | self._attr_target_temperature = None 232 | _LOGGER.warning("Invalid target temperature format for %s: %s", self.entity_id, target_temp_raw) 233 | 234 | # --- Current Temperature (Optional) --- 235 | # Get current temp from instrument's 'current_temperature' property (mapped to outdoor temp) 236 | current_temp_raw = getattr(self._instrument, 'current_temperature', None) 237 | _LOGGER.debug("Raw instrument current_temperature (outdoor): %s", current_temp_raw) 238 | try: 239 | self._attr_current_temperature = float(current_temp_raw) if current_temp_raw is not None else None 240 | except (ValueError, TypeError): 241 | self._attr_current_temperature = None 242 | _LOGGER.debug("Invalid/missing current temperature for %s: %s", self.entity_id, current_temp_raw) 243 | 244 | # --- Update other attributes from instrument.attributes --- 245 | # This fetches the extra attributes prepared by the Instrument class 246 | self._attr_extra_state_attributes = getattr(self._instrument, 'attributes', {}) 247 | 248 | _LOGGER.debug("Final state for %s: Mode=%s, Target=%.1f C, Current=%.1f C, Attrs=%s", 249 | self.entity_id, self._attr_hvac_mode, 250 | self._attr_target_temperature if self._attr_target_temperature is not None else -99.9, 251 | self._attr_current_temperature if self._attr_current_temperature is not None else -99.9, 252 | self._attr_extra_state_attributes) 253 | 254 | 255 | # --- ClimateEntity Properties --- 256 | 257 | @property 258 | def name(self) -> str | None: 259 | """Return the name of the climate device.""" 260 | # Using _attr_has_entity_name = True means HA will combine device name + "Climate" 261 | # If you want full manual control: return f"{self._vehicle_name} Climate" 262 | # Returning None uses the default naming scheme based on has_entity_name 263 | return None # Let HA handle naming based on _attr_has_entity_name 264 | 265 | 266 | @property 267 | def extra_state_attributes(self) -> dict[str, Any] | None: 268 | """Return device specific state attributes.""" 269 | attrs = {} 270 | # Safely get base attributes 271 | base_attrs = getattr(self._instrument, 'attributes', {}) 272 | if isinstance(base_attrs, dict): 273 | attrs.update(base_attrs) 274 | 275 | # Add VIN for easy reference, helpful for service calls 276 | attrs["vin"] = getattr(self._instrument, 'vehicle_vin', None) 277 | # Add other relevant attributes from the instrument 278 | # attrs["outside_temperature"] = getattr(self._instrument, 'outside_temp', None) 279 | 280 | # Filter out None values if desired 281 | return {k: v for k, v in attrs.items() if v is not None} 282 | 283 | 284 | # --- ClimateEntity Methods --- 285 | 286 | async def async_set_temperature(self, **kwargs: Any) -> None: 287 | """Set new target temperature.""" 288 | temperature = kwargs.get(ATTR_TEMPERATURE) 289 | if temperature is None: 290 | return 291 | 292 | _LOGGER.debug("Setting target temperature for %s to %.1f %s", 293 | self.entity_id, temperature, self.temperature_unit) 294 | 295 | vin = getattr(self._instrument, 'vehicle_vin', None) 296 | if not vin: 297 | _LOGGER.error("Cannot set temperature, VIN not found for %s", self.entity_id) 298 | return 299 | 300 | # *** Assumption: audiData has an async_set_climate_temp method *** 301 | # This method might need VIN and temperature (likely in Celsius) 302 | if hasattr(self._audi_data, 'async_set_climate_temp'): 303 | try: 304 | # Convert temperature to Celsius if the API requires it 305 | # The climate entity framework handles unit conversions for display, 306 | # but the API call needs the correct unit. Assume API wants Celsius. 307 | temp_c = temperature 308 | if self.hass.config.units.temperature_unit == UnitOfTemperature.FAHRENHEIT: 309 | # This conversion might already be handled by HA depending on how 310 | # the value is passed, but explicit conversion can be safer. 311 | # Alternatively, the ClimateEntity might provide converted values. 312 | # Let's assume the API call needs Celsius. 313 | # If your API takes Fahrenheit, adjust accordingly. 314 | # temp_c = self.hass.config.units.temperature(temperature, UnitOfTemperature.FAHRENHEIT) # Requires HA util 315 | pass # For now, assume API expects Celsius and HA gives it correctly 316 | 317 | 318 | await self._audi_data.async_set_climate_temp(vin=vin, temperature=temp_c) 319 | # Optimistic update: Update the state locally immediately 320 | self._attr_target_temperature = temperature 321 | self.async_write_ha_state() 322 | _LOGGER.info("Set target temperature for VIN %s to %.1f C (requested %.1f %s)", 323 | vin, temp_c, temperature, self.temperature_unit) 324 | except Exception as e: 325 | _LOGGER.error("Error setting temperature for VIN %s: %s", vin, e) 326 | else: 327 | _LOGGER.error("Audi data object does not have 'async_set_climate_temp' method.") 328 | 329 | 330 | async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 331 | """Set new target hvac mode (calls start/stop services).""" 332 | _LOGGER.debug("Setting HVAC mode for %s to %s", self.entity_id, hvac_mode) 333 | vin = getattr(self._instrument, 'vehicle_vin', None) 334 | if not vin: 335 | _LOGGER.error("Cannot set HVAC mode, VIN not found for %s", self.entity_id) 336 | return 337 | 338 | if hvac_mode == HVACMode.OFF: 339 | await self.async_turn_off() 340 | elif hvac_mode == HVACMode.HEAT_COOL: 341 | # Start climate with current/default settings 342 | # Ideally, retrieve current target temp to pass along 343 | target_temp_c = self._attr_target_temperature # Assumes this is Celsius 344 | await self.async_turn_on(temperature_c=target_temp_c) # Pass temp to turn_on helper 345 | else: 346 | _LOGGER.warning("Unsupported HVAC mode requested: %s", hvac_mode) 347 | 348 | 349 | async def async_turn_on(self, temperature_c: Optional[float] = None) -> None: 350 | """Turn on climate control using the service call logic.""" 351 | _LOGGER.info("Turning on climate for %s", self.entity_id) 352 | vin = getattr(self._instrument, 'vehicle_vin', None) 353 | if not vin: 354 | _LOGGER.error("Cannot turn on climate, VIN not found for %s", self.entity_id) 355 | return 356 | 357 | # Use the service handler logic for consistency 358 | # *** Assumption: audiData object has an start_climate_control method *** 359 | if hasattr(self._audi_data, 'start_climate_control'): 360 | try: 361 | # Get current target temp if not provided, use reasonable default if needed 362 | temp_to_set = temperature_c if temperature_c is not None else self._attr_target_temperature 363 | if temp_to_set is None: 364 | temp_to_set = 21.0 # Default to 21C if no target is set 365 | _LOGGER.debug("No target temp found for turn_on, using default %.1f C", temp_to_set) 366 | 367 | await self._audi_data.start_climate_control(vin=vin, temp_c=temp_to_set) 368 | # Optimistic update 369 | self._attr_hvac_mode = HVACMode.HEAT_COOL 370 | self.async_write_ha_state() 371 | _LOGGER.info("Climate turn on requested for VIN %s", vin) 372 | except Exception as e: 373 | _LOGGER.error("Error turning on climate for VIN %s: %s", vin, e) 374 | else: 375 | _LOGGER.error("Audi data object does not have 'start_climate_control' method.") 376 | 377 | 378 | async def async_turn_off(self) -> None: 379 | """Turn off climate control using the service call logic.""" 380 | _LOGGER.info("Turning off climate for %s", self.entity_id) 381 | vin = getattr(self._instrument, 'vehicle_vin', None) 382 | if not vin: 383 | _LOGGER.error("Cannot turn off climate, VIN not found for %s", self.entity_id) 384 | return 385 | 386 | # Use the service handler logic for consistency 387 | # *** Assumption: audiData object has an stop_climate_control method *** 388 | if hasattr(self._audi_data, 'async_stop_climate'): 389 | try: 390 | await self._audi_data.async_stop_climate(vin=vin) 391 | # Optimistic update 392 | self._attr_hvac_mode = HVACMode.OFF 393 | self.async_write_ha_state() 394 | _LOGGER.info("Climate turn off requested for VIN %s", vin) 395 | except Exception as e: 396 | _LOGGER.error("Error turning off climate for VIN %s: %s", vin, e) 397 | else: 398 | _LOGGER.error("Audi data object does not have 'async_stop_climate' method.") 399 | 400 | # --- Update Handling --- 401 | 402 | async def async_added_to_hass(self) -> None: 403 | """Register callbacks when entity is added.""" 404 | await super().async_added_to_hass() 405 | 406 | # Register for updates specific to climate 407 | self.async_on_remove( 408 | async_dispatcher_connect( 409 | self.hass, CLIMATE_UPDATE, self._async_receive_data # Use CLIMATE_UPDATE signal 410 | ) 411 | ) 412 | _LOGGER.debug("%s registered for %s signals", self.entity_id, CLIMATE_UPDATE) 413 | # Request initial data update if needed (or rely on update_before_add=True) 414 | # await self._instrument.async_update() # If your instrument has an update method 415 | 416 | 417 | @callback 418 | def _async_receive_data(self, instrument: Any) -> None: 419 | """Handle updated data received via dispatcher.""" 420 | # Check if the update is for this specific entity instance 421 | if not instrument or instrument.full_name != self._attr_unique_id: 422 | return 423 | 424 | _LOGGER.debug("Received climate update for %s", self.entity_id) 425 | self._instrument = instrument # Update the internal instrument data 426 | self._update_state_from_instrument() # Parse new state 427 | self.async_write_ha_state() # Schedule HA state update -------------------------------------------------------------------------------- /custom_components/audiconnect/audi_models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from .util import get_attr 3 | 4 | _LOGGER = logging.getLogger(__name__) 5 | 6 | 7 | class VehicleData: 8 | def __init__(self, config_entry): 9 | self.sensors = set() 10 | self.binary_sensors = set() 11 | self.switches = set() 12 | self.device_trackers = set() 13 | self.climates = set() 14 | self.locks = set() 15 | self.config_entry = config_entry 16 | self.vehicle = None 17 | 18 | 19 | class CurrentVehicleDataResponse: 20 | def __init__(self, data): 21 | data = data["CurrentVehicleDataResponse"] 22 | self.request_id = data["requestId"] 23 | self.vin = data["vin"] 24 | 25 | 26 | class VehicleDataResponse: 27 | OLDAPI_MAPPING = { 28 | "frontRightLock": "LOCK_STATE_RIGHT_FRONT_DOOR", 29 | "frontRightOpen": "OPEN_STATE_RIGHT_FRONT_DOOR", 30 | "frontLeftLock": "LOCK_STATE_LEFT_FRONT_DOOR", 31 | "frontLeftOpen": "OPEN_STATE_LEFT_FRONT_DOOR", 32 | "rearRightLock": "LOCK_STATE_RIGHT_REAR_DOOR", 33 | "rearRightOpen": "OPEN_STATE_RIGHT_REAR_DOOR", 34 | "rearLeftLock": "LOCK_STATE_LEFT_REAR_DOOR", 35 | "rearLeftOpen": "OPEN_STATE_LEFT_REAR_DOOR", 36 | "trunkLock": "LOCK_STATE_TRUNK_LID", 37 | "trunkOpen": "OPEN_STATE_TRUNK_LID", 38 | "bonnetLock": "LOCK_STATE_HOOD", 39 | "bonnetOpen": "OPEN_STATE_HOOD", 40 | "sunRoofWindow": "STATE_SUN_ROOF_MOTOR_COVER", 41 | "frontLeftWindow": "STATE_LEFT_FRONT_WINDOW", 42 | "frontRightWindow": "STATE_RIGHT_FRONT_WINDOW", 43 | "rearLeftWindow": "STATE_LEFT_REAR_WINDOW", 44 | "rearRightWindow": "STATE_RIGHT_REAR_WINDOW", 45 | "roofCoverWindow": "STATE_ROOF_COVER_WINDOW", 46 | } 47 | 48 | def __init__(self, data): 49 | self.data_fields = [] 50 | self.states = [] 51 | 52 | self._tryAppendFieldWithTs( 53 | data, "TOTAL_RANGE", ["fuelStatus", "rangeStatus", "value", "totalRange_km"] 54 | ) 55 | self._tryAppendFieldWithTs( 56 | data, 57 | "TANK_LEVEL_IN_PERCENTAGE", 58 | ["measurements", "fuelLevelStatus", "value", "currentFuelLevel_pct"], 59 | ) 60 | self._tryAppendFieldWithTs( 61 | data, 62 | "UTC_TIME_AND_KILOMETER_STATUS", 63 | ["measurements", "odometerStatus", "value", "odometer"], 64 | ) 65 | self._tryAppendFieldWithTs( 66 | data, 67 | "MAINTENANCE_INTERVAL_TIME_TO_INSPECTION", 68 | [ 69 | "vehicleHealthInspection", 70 | "maintenanceStatus", 71 | "value", 72 | "inspectionDue_days", 73 | ], 74 | ) 75 | self._tryAppendFieldWithTs( 76 | data, 77 | "MAINTENANCE_INTERVAL_DISTANCE_TO_INSPECTION", 78 | [ 79 | "vehicleHealthInspection", 80 | "maintenanceStatus", 81 | "value", 82 | "inspectionDue_km", 83 | ], 84 | ) 85 | 86 | self._tryAppendFieldWithTs( 87 | data, 88 | "MAINTENANCE_INTERVAL_TIME_TO_OIL_CHANGE", 89 | [ 90 | "vehicleHealthInspection", 91 | "maintenanceStatus", 92 | "value", 93 | "oilServiceDue_days", 94 | ], 95 | ) 96 | self._tryAppendFieldWithTs( 97 | data, 98 | "MAINTENANCE_INTERVAL_DISTANCE_TO_OIL_CHANGE", 99 | [ 100 | "vehicleHealthInspection", 101 | "maintenanceStatus", 102 | "value", 103 | "oilServiceDue_km", 104 | ], 105 | ) 106 | 107 | self._tryAppendFieldWithTs( 108 | data, 109 | "OIL_LEVEL_DIPSTICKS_PERCENTAGE", 110 | ["oilLevel", "oilLevelStatus", "value", "value"], 111 | ) 112 | self._tryAppendFieldWithTs( 113 | data, 114 | "ADBLUE_RANGE", 115 | ["measurements", "rangeStatus", "value", "adBlueRange"], 116 | ) 117 | 118 | self._tryAppendFieldWithTs( 119 | data, "LIGHT_STATUS", ["vehicleLights", "lightsStatus", "value", "lights"] 120 | ) 121 | 122 | self.appendWindowState(data) 123 | self.appendDoorState(data) 124 | 125 | self._tryAppendStateWithTs( 126 | data, "carType", -1, ["fuelStatus", "rangeStatus", "value", "carType"] 127 | ) 128 | 129 | self._tryAppendStateWithTs( 130 | data, 131 | "engineTypeFirstEngine", 132 | -2, 133 | ["fuelStatus", "rangeStatus", "value", "primaryEngine", "type"], 134 | ) 135 | self._tryAppendStateWithTs( 136 | data, 137 | "primaryEngineRange", 138 | -2, 139 | [ 140 | "fuelStatus", 141 | "rangeStatus", 142 | "value", 143 | "primaryEngine", 144 | "remainingRange_km", 145 | ], 146 | ) 147 | self._tryAppendStateWithTs( 148 | data, 149 | "primaryEngineRangePercent", 150 | -2, 151 | ["fuelStatus", "rangeStatus", "value", "primaryEngine", "currentSOC_pct"], 152 | ) 153 | self._tryAppendStateWithTs( 154 | data, 155 | "engineTypeSecondEngine", 156 | -2, 157 | ["fuelStatus", "rangeStatus", "value", "secondaryEngine", "type"], 158 | ) 159 | self._tryAppendStateWithTs( 160 | data, 161 | "secondaryEngineRange", 162 | -2, 163 | [ 164 | "fuelStatus", 165 | "rangeStatus", 166 | "value", 167 | "secondaryEngine", 168 | "remainingRange_km", 169 | ], 170 | ) 171 | self._tryAppendStateWithTs( 172 | data, 173 | "secondaryEngineRangePercent", 174 | -2, 175 | ["fuelStatus", "rangeStatus", "value", "secondaryEngine", "currentSOC_pct"], 176 | ) 177 | self._tryAppendStateWithTs( 178 | data, 179 | "hybridRange", 180 | -1, 181 | ["fuelStatus", "rangeStatus", "value", "totalRange_km"], 182 | ) 183 | self._tryAppendStateWithTs( 184 | data, 185 | "stateOfCharge", 186 | -1, 187 | ["charging", "batteryStatus", "value", "currentSOC_pct"], 188 | ) 189 | self._tryAppendStateWithTs( 190 | data, 191 | "chargingState", 192 | -1, 193 | ["charging", "chargingStatus", "value", "chargingState"], 194 | ) 195 | self._tryAppendStateWithTs( 196 | data, 197 | "chargeMode", 198 | -1, 199 | ["charging", "chargingStatus", "value", "chargeMode"], 200 | ) 201 | self._tryAppendStateWithTs( 202 | data, 203 | "chargingPower", 204 | -1, 205 | ["charging", "chargingStatus", "value", "chargePower_kW"], 206 | ) 207 | self._tryAppendStateWithTs( 208 | data, 209 | "actualChargeRate", 210 | -1, 211 | ["charging", "chargingStatus", "value", "chargeRate_kmph"], 212 | ) 213 | self._tryAppendStateWithTs( 214 | data, 215 | "chargingMode", 216 | -1, 217 | ["charging", "chargingStatus", "value", "chargeType"], 218 | ) 219 | self._tryAppendStateWithTs( 220 | data, 221 | "targetstateOfCharge", 222 | -1, 223 | ["charging", "chargingSettings", "value", "targetSOC_pct"], 224 | ) 225 | self._tryAppendStateWithTs( 226 | data, 227 | "plugState", 228 | -1, 229 | ["charging", "plugStatus", "value", "plugConnectionState"], 230 | ) 231 | self._tryAppendStateWithTs( 232 | data, 233 | "remainingChargingTime", 234 | -1, 235 | [ 236 | "charging", 237 | "chargingStatus", 238 | "value", 239 | "remainingChargingTimeToComplete_min", 240 | ], 241 | ) 242 | self._tryAppendStateWithTs( 243 | data, 244 | "plugLockState", 245 | -1, 246 | ["charging", "plugStatus", "value", "plugLockState"], 247 | ) 248 | self._tryAppendStateWithTs( 249 | data, 250 | "externalPower", 251 | -1, 252 | ["charging", "plugStatus", "value", "externalPower"], 253 | ) 254 | self._tryAppendStateWithTs( 255 | data, 256 | "plugledColor", 257 | -1, 258 | ["charging", "plugStatus", "value", "ledColor"], 259 | ) 260 | self._tryAppendStateWithTs( 261 | data, 262 | "climatisationState", 263 | -1, 264 | ["climatisation", "auxiliaryHeatingStatus", "value", "climatisationState"], 265 | ) 266 | # 2024 Q4 updated data structure for climate data 267 | self._tryAppendStateWithTs( 268 | data, 269 | "climatisationState", 270 | -1, 271 | ["climatisation", "climatisationStatus", "value", "climatisationState"], 272 | ) 273 | self._tryAppendStateWithTs( 274 | data, 275 | "remainingClimatisationTime", 276 | -1, 277 | [ 278 | "climatisation", 279 | "climatisationStatus", 280 | "value", 281 | "remainingClimatisationTime_min", 282 | ], 283 | ) 284 | 285 | def _tryAppendStateWithTs(self, json, name, tsoff, loc): 286 | _LOGGER.debug( 287 | "TRY APPEND STATE: Searching for '%s' at location=%s, tsoff=%s", 288 | name, 289 | loc, 290 | tsoff, 291 | ) 292 | 293 | ts = None 294 | val = self._getFromJson(json, loc) 295 | # _LOGGER.debug("Initial value retrieved for '%s': %s", name, val) 296 | 297 | if val is not None: 298 | loc[tsoff:] = ["carCapturedTimestamp"] 299 | # _LOGGER.debug("Updated loc for timestamp retrieval: %s", loc) 300 | ts = self._getFromJson(json, loc) 301 | # _LOGGER.debug("Timestamp retrieved for '%s': %s", name, ts) 302 | 303 | if val is not None and ts: 304 | self.states.append({"name": name, "value": val, "measure_time": ts}) 305 | _LOGGER.debug( 306 | "TRY APPEND STATE: Found '%s' with value=%s, tsoff=%s, loc=%s, ts=%s", 307 | name, 308 | val, 309 | tsoff, 310 | loc, 311 | ts, 312 | ) 313 | else: 314 | if val is None: 315 | _LOGGER.debug( 316 | "TRY APPEND STATE: Value for '%s' is None; not appending state.", 317 | name, 318 | ) 319 | elif not ts: 320 | _LOGGER.debug( 321 | "TRY APPEND STATE: Timestamp for '%s' is None or missing; not appending state.", 322 | name, 323 | ) 324 | 325 | def _tryAppendFieldWithTs(self, json, textId, loc): 326 | _LOGGER.debug( 327 | "TRY APPEND FIELD: Searching for '%s' at location=%s", 328 | textId, 329 | loc, 330 | ) 331 | 332 | ts = None 333 | val = self._getFromJson(json, loc) 334 | # _LOGGER.debug("Initial value retrieved for '%s': %s", textId, val) 335 | 336 | if val is not None: 337 | loc[-1:] = ["carCapturedTimestamp"] 338 | # _LOGGER.debug("Updated loc for timestamp retrieval: %s", loc) 339 | ts = self._getFromJson(json, loc) 340 | # _LOGGER.debug("Timestamp retrieved for '%s': %s", textId, ts) 341 | 342 | if val is not None and ts: 343 | self.data_fields.append( 344 | Field( 345 | { 346 | "textId": textId, 347 | "value": val, 348 | "tsCarCaptured": ts, 349 | } 350 | ) 351 | ) 352 | _LOGGER.debug( 353 | "TRY APPEND FIELD: Found '%s' with value=%s, loc=%s, ts=%s", 354 | textId, 355 | val, 356 | loc, 357 | ts, 358 | ) 359 | else: 360 | if val is None: 361 | _LOGGER.debug( 362 | "TRY APPEND FIELD: Value for '%s' is None or missing; not appending field.", 363 | textId, 364 | ) 365 | elif not ts: 366 | _LOGGER.debug( 367 | "TRY APPEND FIELD: Timestamp for '%s' is None or missing; not appending field.", 368 | textId, 369 | ) 370 | 371 | def _getFromJson(self, json, loc): 372 | child = json 373 | for i in loc: 374 | if i not in child: 375 | return None 376 | child = child[i] 377 | return child 378 | 379 | def appendDoorState(self, data): 380 | _LOGGER.debug("APPEND DOOR: Starting to append doors...") 381 | doors = get_attr(data, "access.accessStatus.value.doors", []) 382 | tsCarCapturedAccess = get_attr( 383 | data, "access.accessStatus.value.carCapturedTimestamp" 384 | ) 385 | _LOGGER.debug( 386 | "APPEND DOOR: Timestamp captured from car: %s", tsCarCapturedAccess 387 | ) 388 | for door in doors: 389 | status = door["status"] 390 | name = door["name"] 391 | _LOGGER.debug( 392 | "APPEND DOOR: Processing door: %s with status: %s", name, status 393 | ) 394 | if name + "Lock" not in self.OLDAPI_MAPPING: 395 | _LOGGER.debug( 396 | "APPEND DOOR: Skipping door not mapped in OLDAPI_MAPPING: %s", name 397 | ) 398 | continue 399 | lock = "0" 400 | open = "0" 401 | unsupported = False 402 | for state in status: 403 | if state == "unsupported": 404 | unsupported = True 405 | _LOGGER.debug("APPEND DOOR: Unsupported state for door: %s", name) 406 | if state == "locked": 407 | lock = "2" 408 | if state == "closed": 409 | open = "3" 410 | if not unsupported: 411 | doorFieldLock = { 412 | "textId": self.OLDAPI_MAPPING[name + "Lock"], 413 | "value": lock, 414 | "tsCarCaptured": tsCarCapturedAccess, 415 | } 416 | _LOGGER.debug( 417 | "APPEND DOOR: Appended door lock field: %s", doorFieldLock 418 | ) 419 | self.data_fields.append(Field(doorFieldLock)) 420 | 421 | doorFieldOpen = { 422 | "textId": self.OLDAPI_MAPPING[name + "Open"], 423 | "value": open, 424 | "tsCarCaptured": tsCarCapturedAccess, 425 | } 426 | _LOGGER.debug( 427 | "APPEND DOOR: Appended door open field: %s", doorFieldOpen 428 | ) 429 | self.data_fields.append(Field(doorFieldOpen)) 430 | _LOGGER.debug("APPEND DOOR: Finished appending doors") 431 | 432 | def appendWindowState(self, data): 433 | _LOGGER.debug("APPEND WINDOW: Starting to append windows...") 434 | windows = get_attr(data, "access.accessStatus.value.windows", []) 435 | tsCarCapturedAccess = get_attr( 436 | data, "access.accessStatus.value.carCapturedTimestamp" 437 | ) 438 | _LOGGER.debug( 439 | "APPEND WINDOW: Timestamp captured from car: %s", tsCarCapturedAccess 440 | ) 441 | for window in windows: 442 | name = window["name"] 443 | status = window["status"] 444 | _LOGGER.debug( 445 | "APPEND WINDOW: Processing window: %s with status: %s", name, status 446 | ) 447 | if ( 448 | status[0] == "unsupported" 449 | ) or name + "Window" not in self.OLDAPI_MAPPING: 450 | _LOGGER.debug( 451 | "APPEND WINDOW: Skipping unsupported window or not mapped in OLDAPI_MAPPING: %s", 452 | name, 453 | ) 454 | continue 455 | windowField = { 456 | "textId": self.OLDAPI_MAPPING[name + "Window"], 457 | "value": "3" if status[0] == "closed" else "0", 458 | "tsCarCaptured": tsCarCapturedAccess, 459 | } 460 | _LOGGER.debug("APPEND WINDOW: Appended window field: %s", windowField) 461 | self.data_fields.append(Field(windowField)) 462 | _LOGGER.debug("APPEND WINDOW: Finished appending windows") 463 | 464 | 465 | class TripDataResponse: 466 | def __init__(self, data): 467 | self.data_fields = [] 468 | 469 | self.tripID = data["tripID"] 470 | 471 | self.averageElectricEngineConsumption = None 472 | if "averageElectricEngineConsumption" in data: 473 | self.averageElectricEngineConsumption = ( 474 | float(data["averageElectricEngineConsumption"]) / 10 475 | ) 476 | 477 | self.averageFuelConsumption = None 478 | if "averageFuelConsumption" in data: 479 | self.averageFuelConsumption = float(data["averageFuelConsumption"]) / 10 480 | 481 | self.averageSpeed = None 482 | if "averageSpeed" in data: 483 | self.averageSpeed = int(data["averageSpeed"]) 484 | 485 | self.mileage = None 486 | if "mileage" in data: 487 | self.mileage = int(data["mileage"]) 488 | 489 | self.startMileage = None 490 | if "startMileage" in data: 491 | self.startMileage = int(data["startMileage"]) 492 | 493 | self.traveltime = None 494 | if "traveltime" in data: 495 | self.traveltime = int(data["traveltime"]) 496 | 497 | self.timestamp = None 498 | if "timestamp" in data: 499 | self.timestamp = data["timestamp"] 500 | 501 | self.overallMileage = None 502 | if "overallMileage" in data: 503 | self.overallMileage = int(data["overallMileage"]) 504 | 505 | self.zeroEmissionDistance = None 506 | if "zeroEmissionDistance" in data: 507 | self.zeroEmissionDistance = int(data["zeroEmissionDistance"]) 508 | 509 | 510 | class Field: 511 | IDS = { 512 | "0x0": "UNKNOWN", 513 | "0x0101010002": "UTC_TIME_AND_KILOMETER_STATUS", 514 | "0x0203010001": "MAINTENANCE_INTERVAL_DISTANCE_TO_OIL_CHANGE", 515 | "0x0203010002": "MAINTENANCE_INTERVAL_TIME_TO_OIL_CHANGE", 516 | "0x0203010003": "MAINTENANCE_INTERVAL_DISTANCE_TO_INSPECTION", 517 | "0x0203010004": "MAINTENANCE_INTERVAL_TIME_TO_INSPECTION", 518 | "0x0203010006": "MAINTENANCE_INTERVAL_ALARM_INSPECTION", 519 | "0x0203010007": "MAINTENANCE_INTERVAL_MONTHLY_MILEAGE", 520 | "0x0203010005": "WARNING_OIL_CHANGE", 521 | "0x0204040001": "OIL_LEVEL_AMOUNT_IN_LITERS", 522 | "0x0204040002": "OIL_LEVEL_MINIMUM_WARNING", 523 | "0x0204040003": "OIL_LEVEL_DIPSTICKS_PERCENTAGE", 524 | "0x02040C0001": "ADBLUE_RANGE", 525 | "0x0301010001": "LIGHT_STATUS", 526 | "0x0301030001": "BRAKING_STATUS", 527 | "0x0301030005": "TOTAL_RANGE", 528 | "0x030103000A": "TANK_LEVEL_IN_PERCENTAGE", 529 | "0x0301040001": "LOCK_STATE_LEFT_FRONT_DOOR", 530 | "0x0301040002": "OPEN_STATE_LEFT_FRONT_DOOR", 531 | "0x0301040003": "SAFETY_STATE_LEFT_FRONT_DOOR", 532 | "0x0301040004": "LOCK_STATE_LEFT_REAR_DOOR", 533 | "0x0301040005": "OPEN_STATE_LEFT_REAR_DOOR", 534 | "0x0301040006": "SAFETY_STATE_LEFT_REAR_DOOR", 535 | "0x0301040007": "LOCK_STATE_RIGHT_FRONT_DOOR", 536 | "0x0301040008": "OPEN_STATE_RIGHT_FRONT_DOOR", 537 | "0x0301040009": "SAFETY_STATE_RIGHT_FRONT_DOOR", 538 | "0x030104000A": "LOCK_STATE_RIGHT_REAR_DOOR", 539 | "0x030104000B": "OPEN_STATE_RIGHT_REAR_DOOR", 540 | "0x030104000C": "SAFETY_STATE_RIGHT_REAR_DOOR", 541 | "0x030104000D": "LOCK_STATE_TRUNK_LID", 542 | "0x030104000E": "OPEN_STATE_TRUNK_LID", 543 | "0x030104000F": "SAFETY_STATE_TRUNK_LID", 544 | "0x0301040010": "LOCK_STATE_HOOD", 545 | "0x0301040011": "OPEN_STATE_HOOD", 546 | "0x0301040012": "SAFETY_STATE_HOOD", 547 | "0x0301050001": "STATE_LEFT_FRONT_WINDOW", 548 | "0x0301050003": "STATE_LEFT_REAR_WINDOW", 549 | "0x0301050005": "STATE_RIGHT_FRONT_WINDOW", 550 | "0x0301050007": "STATE_RIGHT_REAR_WINDOW", 551 | "0x0301050009": "STATE_DECK", 552 | "0x030105000B": "STATE_SUN_ROOF_MOTOR_COVER", 553 | "0x0301030006": "PRIMARY_RANGE", 554 | "0x0301030007": "PRIMARY_DRIVE", 555 | "0x0301030008": "SECONDARY_RANGE", 556 | "0x0301030009": "SECONDARY_DRIVE", 557 | "0x0301030002": "STATE_OF_CHARGE", 558 | "0x0301020001": "TEMPERATURE_OUTSIDE", 559 | "0x0202": "ACTIVE_INSTRUMENT_CLUSTER_WARNING", 560 | } 561 | 562 | def __init__(self, data): 563 | self.name = None 564 | self.id = data.get("id") 565 | self.unit = data.get("unit") 566 | self.value = data.get("value") 567 | self.measure_time = data.get("tsTssReceivedUtc") 568 | if self.measure_time is None: 569 | self.measure_time = data.get("tsCarCaptured") 570 | self.send_time = data.get("tsCarSentUtc") 571 | self.measure_mileage = data.get("milCarCaptured") 572 | self.send_mileage = data.get("milCarSent") 573 | 574 | for field_id, name in self.IDS.items(): 575 | if field_id == self.id: 576 | self.name = name 577 | break 578 | if self.name is None: 579 | # No direct mapping found - maybe we've at least got a text id 580 | self.name = data.get("textId") 581 | 582 | def __str__(self): 583 | str_rep = str(self.name) + " " + str(self.value) 584 | if self.unit is not None: 585 | str_rep += self.unit 586 | return str_rep 587 | 588 | 589 | class Vehicle: 590 | def __init__(self): 591 | self.vin = "" 592 | self.csid = "" 593 | self.model = "" 594 | self.model_year = "" 595 | self.model_family = "" 596 | self.title = "" 597 | 598 | def parse(self, data): 599 | self.vin = data.get("vin") 600 | self.csid = data.get("csid") 601 | if ( 602 | data.get("vehicle") is not None 603 | and data.get("vehicle").get("media") is not None 604 | ): 605 | self.model = data.get("vehicle").get("media").get("longName") 606 | if ( 607 | data.get("vehicle") is not None 608 | and data.get("vehicle").get("core") is not None 609 | ): 610 | self.model_year = data.get("vehicle").get("core").get("modelYear") 611 | if data.get("nickname") is not None and len(data.get("nickname")) > 0: 612 | self.title = data.get("nickname") 613 | elif ( 614 | data.get("vehicle") is not None 615 | and data.get("vehicle").get("media") is not None 616 | ): 617 | self.title = data.get("vehicle").get("media").get("shortName") 618 | 619 | def __str__(self): 620 | return str(self.__dict__) 621 | 622 | 623 | class VehiclesResponse: 624 | def __init__(self): 625 | self.vehicles = [] 626 | self.blacklisted_vins = 0 627 | 628 | def parse(self, data): 629 | for item in data.get("userVehicles"): 630 | vehicle = Vehicle() 631 | vehicle.parse(item) 632 | self.vehicles.append(vehicle) 633 | -------------------------------------------------------------------------------- /custom_components/audiconnect/dashboard.py: -------------------------------------------------------------------------------- 1 | # Utilities for integration with Home Assistant (directly or via MQTT) 2 | 3 | import logging 4 | import re 5 | 6 | from homeassistant.components.sensor import ( 7 | SensorDeviceClass, 8 | SensorStateClass, 9 | ) 10 | from homeassistant.components.binary_sensor import BinarySensorDeviceClass 11 | from homeassistant.const import ( 12 | PERCENTAGE, 13 | UnitOfTime, 14 | UnitOfLength, 15 | UnitOfTemperature, 16 | UnitOfPower, 17 | UnitOfElectricCurrent, 18 | EntityCategory, 19 | ) 20 | from .util import parse_datetime 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | class Instrument: 26 | def __init__(self, component, attr, name, icon=None): 27 | self._attr = attr 28 | self._component = component 29 | self._name = name 30 | self._connection = None 31 | self._vehicle = None 32 | self._icon = icon 33 | 34 | def __repr__(self): 35 | return self.full_name 36 | 37 | def camel2slug(self, s): 38 | """Convert camelCase to camel_case. 39 | >>> camel2slug('fooBar') 40 | 'foo_bar' 41 | """ 42 | return re.sub("([A-Z])", "_\\1", s).lower().lstrip("_") 43 | 44 | @property 45 | def slug_attr(self): 46 | return self.camel2slug(self._attr.replace(".", "_")) 47 | 48 | def setup(self, connection, vehicle, mutable=True, **config): 49 | self._connection = connection 50 | self._vehicle = vehicle 51 | 52 | if not mutable and self.is_mutable: 53 | _LOGGER.debug("Skipping %s because mutable", self) 54 | return False 55 | 56 | if not self.is_supported: 57 | if self._component == "climate" and not self.is_climate_supported(vehicle): 58 | _LOGGER.debug("%s Climate component support check failed.", self) 59 | return False 60 | # _LOGGER.debug( 61 | # "%s (%s:%s) is not supported", self, type(self).__name__, self._attr, 62 | # ) 63 | return False 64 | 65 | # _LOGGER.debug("%s is supported", self) 66 | 67 | return True 68 | 69 | @property 70 | def component(self): 71 | return self._component 72 | 73 | @property 74 | def icon(self): 75 | return self._icon 76 | 77 | @property 78 | def name(self): 79 | return self._name 80 | 81 | @property 82 | def attr(self): 83 | return self._attr 84 | 85 | @property 86 | def vehicle_name(self): 87 | return self._vehicle.title 88 | 89 | @property 90 | def full_name(self): 91 | return "{} {}".format(self.vehicle_name, self._name) 92 | 93 | @property 94 | def vehicle_model(self): 95 | return self._vehicle.model 96 | 97 | @property 98 | def vehicle_model_year(self): 99 | return self._vehicle.model_year 100 | 101 | @property 102 | def vehicle_model_family(self): 103 | return self._vehicle.model_family 104 | 105 | @property 106 | def vehicle_vin(self): 107 | return self._vehicle.vin 108 | 109 | @property 110 | def vehicle_csid(self): 111 | return self._vehicle.csid 112 | 113 | @property 114 | def is_mutable(self): 115 | raise NotImplementedError("Must be set") 116 | 117 | @property 118 | def is_supported(self): 119 | supported = self._attr + "_supported" 120 | if hasattr(self._vehicle, supported): 121 | return getattr(self._vehicle, supported) 122 | if hasattr(self._vehicle, self._attr): 123 | return True 124 | return False 125 | 126 | @property 127 | def str_state(self): 128 | # Keep existing logic for other components 129 | # Add specific formatting for climate if desired, otherwise use raw state 130 | if self._component == "climate": 131 | state_val = self.state # Get the raw state ('on'/'off') 132 | temp_val = self.target_temperature # Get the temp 133 | if temp_val is not None: 134 | return f"{state_val} ({temp_val}°C)" 135 | else: 136 | return f"{state_val}" 137 | # Existing logic... 138 | if self.unit: 139 | return "{} {}".format(self.state, self.unit) 140 | else: 141 | return "%s" % self.state 142 | 143 | @property 144 | def state(self): 145 | """Return the primary state for the component.""" 146 | if self._component == "climate": 147 | # For climate, the primary state is the HVAC mode ('on', 'off', etc.) 148 | # Get it from the vehicle property 149 | return getattr(self._vehicle, 'climatisation_state', 'unknown') # <--- Access vehicle property 150 | elif hasattr(self._vehicle, self._attr): 151 | # Existing logic for sensors etc. using self._attr 152 | return getattr(self._vehicle, self._attr) 153 | # Fallback if needed (though less likely now with explicit climate handling) 154 | # return self._vehicle.get_attr(self._attr) # Original fallback if get_attr exists 155 | return None 156 | 157 | @property 158 | def target_temperature(self): 159 | """Return the target temperature, only relevant for climate.""" 160 | if self._component == "climate": 161 | # Get target temp directly from the vehicle property 162 | return getattr(self._vehicle, 'target_temperature', None) # <--- Access vehicle property 163 | return None # Return None for non-climate components 164 | 165 | 166 | # Add other properties if AudiClimate needs them from the Instrument 167 | # E.g., current_temperature (if available on vehicle) 168 | @property 169 | def current_temperature(self): 170 | if self._component == "climate": 171 | # Assuming AudiConnectVehicle has an 'outdoor_temperature' property 172 | return getattr(self._vehicle, 'outdoor_temperature', None) 173 | return None 174 | 175 | # Ensure vehicle_vin is accessible if needed by AudiClimate 176 | @property 177 | def vehicle_vin(self): 178 | return self._vehicle.vin 179 | 180 | @property 181 | def attributes(self): 182 | """Return additional state attributes.""" 183 | attrs = {} 184 | if self._component == "climate": 185 | # Add specific climate attributes if needed 186 | attrs["remaining_time_min"] = getattr(self._vehicle, 'remaining_climatisation_time', None) 187 | attrs["window_heating_enabled"] = getattr(self._vehicle, 'windowHeatingEnabled', None) # Assuming stored in state 188 | attrs["zone_front_left_enabled"] = getattr(self._vehicle, 'zoneFrontLeftEnabled', None) # Assuming stored in state 189 | attrs["zone_front_right_enabled"] = getattr(self._vehicle, 'zoneFrontRightEnabled', None)# Assuming stored in state 190 | # Filter out None values 191 | attrs = {k: v for k, v in attrs.items() if v is not None} 192 | # You might add common attributes here too 193 | return attrs 194 | 195 | 196 | 197 | # Specific check for climate component support 198 | def is_climate_supported(self, vehicle): 199 | return vehicle.climatisation_state_supported and vehicle.target_temperature_supported 200 | 201 | class Sensor(Instrument): 202 | def __init__( 203 | self, 204 | attr, 205 | name, 206 | icon=None, 207 | unit=None, 208 | state_class=None, 209 | device_class=None, 210 | entity_category=None, 211 | extra_state_attributes=None, 212 | ): 213 | super().__init__(component="sensor", attr=attr, name=name, icon=icon) 214 | self.device_class = device_class 215 | self._unit = unit 216 | self.state_class = state_class 217 | self.entity_category = entity_category 218 | self.extra_state_attributes = extra_state_attributes 219 | self._convert = False 220 | 221 | @property 222 | def is_mutable(self): 223 | return False 224 | 225 | @property 226 | def str_state(self): 227 | if self.unit: 228 | return "{} {}".format(self.state, self.unit) 229 | else: 230 | return "%s" % self.state 231 | 232 | @property 233 | def state(self): 234 | return super().state 235 | 236 | @property 237 | def unit(self): 238 | supported = self._attr + "_unit" 239 | if hasattr(self._vehicle, supported): 240 | return getattr(self._vehicle, supported) 241 | 242 | return self._unit 243 | 244 | 245 | class BinarySensor(Instrument): 246 | def __init__(self, attr, name, device_class=None, icon=None, entity_category=None): 247 | super().__init__(component="binary_sensor", attr=attr, name=name, icon=icon) 248 | self.device_class = device_class 249 | self.entity_category = entity_category 250 | 251 | @property 252 | def is_mutable(self): 253 | return False 254 | 255 | @property 256 | def str_state(self): 257 | if self.device_class in ["door", "window"]: 258 | return "Open" if self.state else "Closed" 259 | if self.device_class == "safety": 260 | return "Warning!" if self.state else "OK" 261 | if self.device_class == "plug": 262 | return "Charging" if self.state else "Plug removed" 263 | if self.device_class == "lock": 264 | return "Unlocked" if self.state else "Locked" 265 | if self.state is None: 266 | _LOGGER.error("Can not encode state %s:%s", self._attr, self.state) 267 | return "?" 268 | return "On" if self.state else "Off" 269 | 270 | @property 271 | def state(self): 272 | val = super().state 273 | if isinstance(val, (bool, list)): 274 | # for list (e.g. bulb_failures): 275 | # empty list (False) means no problem 276 | return bool(val) 277 | elif isinstance(val, str): 278 | return val != "Normal" 279 | return val 280 | 281 | @property 282 | def is_on(self): 283 | return self.state 284 | 285 | 286 | class Lock(Instrument): 287 | def __init__(self): 288 | super().__init__(component="lock", attr="lock", name="Door lock") 289 | 290 | @property 291 | def is_mutable(self): 292 | return True 293 | 294 | @property 295 | def str_state(self): 296 | return "Locked" if self.state else "Unlocked" 297 | 298 | @property 299 | def state(self): 300 | return self._vehicle.doors_trunk_status == "Locked" 301 | 302 | @property 303 | def is_locked(self): 304 | return self.state 305 | 306 | async def lock(self): 307 | await self._connection.set_vehicle_lock(self.vehicle_vin, True) 308 | 309 | async def unlock(self): 310 | await self._connection.set_vehicle_lock(self.vehicle_vin, False) 311 | 312 | 313 | class Switch(Instrument): 314 | def __init__(self, attr, name, icon): 315 | super().__init__(component="switch", attr=attr, name=name, icon=icon) 316 | 317 | @property 318 | def is_mutable(self): 319 | return True 320 | 321 | @property 322 | def str_state(self): 323 | return "On" if self.state else "Off" 324 | 325 | def is_on(self): 326 | return self.state 327 | 328 | def turn_on(self): 329 | pass 330 | 331 | def turn_off(self): 332 | pass 333 | 334 | 335 | class Climate(Instrument): 336 | """Represents the Climate Control component.""" 337 | def __init__(self): 338 | # Use a relevant attribute name that might exist or just a placeholder 339 | # The core functionality relies on the component type being 'climate' 340 | # and the HA entity accessing properties via the vehicle object. 341 | super().__init__( 342 | component="climate", 343 | attr="climate_control", # Placeholder attribute name 344 | name="Climate Control", # Entity name suffix 345 | icon="mdi:thermostat", # Default climate icon 346 | ) 347 | 348 | @property 349 | def is_mutable(self): 350 | # Climate control involves actions (setting temp, turning on/off) 351 | return True 352 | 353 | @property 354 | def is_supported(self): 355 | # Override is_supported specifically for Climate 356 | # Check if the vehicle object supports both essential climate properties 357 | # Ensure AudiConnectVehicle has these properties defined 358 | if self._vehicle: 359 | has_state = getattr(self._vehicle, "climatisation_state_supported", False) 360 | has_target = getattr(self._vehicle, "target_temperature_supported", False) 361 | _LOGGER.debug("Climate support check: State=%s, TargetTemp=%s", has_state, has_target) 362 | return has_state and has_target 363 | return False 364 | 365 | 366 | class Preheater(Instrument): 367 | def __init__(self): 368 | super().__init__( 369 | component="switch", 370 | attr="preheater_active", 371 | name="Preheater", 372 | icon="mdi:radiator", 373 | ) 374 | 375 | @property 376 | def is_mutable(self): 377 | return True 378 | 379 | @property 380 | def str_state(self): 381 | return "On" if self.state else "Off" 382 | 383 | def is_on(self): 384 | return self.state 385 | 386 | async def turn_on(self): 387 | await self._connection.set_vehicle_pre_heater(self.vehicle_vin, True) 388 | 389 | async def turn_off(self): 390 | await self._connection.set_vehicle_pre_heater(self.vehicle_vin, False) 391 | 392 | 393 | class Position(Instrument): 394 | def __init__(self): 395 | super().__init__(component="device_tracker", attr="position", name="Position") 396 | 397 | @property 398 | def is_mutable(self): 399 | return False 400 | 401 | @property 402 | def state(self): 403 | state = super().state or {} 404 | return ( 405 | state.get("latitude", None), 406 | state.get("longitude", None), 407 | state.get("timestamp", None), 408 | state.get("parktime", None), 409 | ) 410 | 411 | @property 412 | def str_state(self): 413 | state = super().state or {} 414 | ts = state.get("timestamp") 415 | pt = state.get("parktime") 416 | return ( 417 | state.get("latitude", None), 418 | state.get("longitude", None), 419 | str(ts.astimezone(tz=None)) if ts else None, 420 | str(pt.astimezone(tz=None)) if pt else None, 421 | ) 422 | 423 | 424 | class TripData(Instrument): 425 | def __init__(self, attr, name): 426 | super().__init__(component="sensor", attr=attr, name=name) 427 | self.device_class = SensorDeviceClass.TIMESTAMP 428 | self.unit = None 429 | self.state_class = None 430 | self.entity_category = None 431 | 432 | @property 433 | def is_mutable(self): 434 | return False 435 | 436 | @property 437 | def str_state(self): 438 | val = super().state 439 | txt = "" 440 | 441 | if val["averageElectricEngineConsumption"] is not None: 442 | txt = "{}{}_kWh__".format(txt, val["averageElectricEngineConsumption"]) 443 | 444 | if val["averageFuelConsumption"] is not None: 445 | txt = "{}{}_ltr__".format(txt, val["averageFuelConsumption"]) 446 | 447 | return "{}{}_kmh__{}:{:02d}h_({}_m)__{}_km__{}-{}_km".format( 448 | txt, 449 | val["averageSpeed"], 450 | int(val["traveltime"] / 60), 451 | val["traveltime"] % 60, 452 | val["traveltime"], 453 | val["mileage"], 454 | val["startMileage"], 455 | val["overallMileage"], 456 | ) 457 | 458 | @property 459 | def state(self): 460 | td = super().state 461 | return parse_datetime(td["timestamp"]) 462 | 463 | @property 464 | def extra_state_attributes(self): 465 | td = super().state 466 | attr = { 467 | "averageElectricEngineConsumption": td.get( 468 | "averageElectricEngineConsumption", None 469 | ), 470 | "averageFuelConsumption": td.get("averageFuelConsumption", None), 471 | "averageSpeed": td.get("averageSpeed", None), 472 | "mileage": td.get("mileage", None), 473 | "overallMileage": td.get("overallMileage", None), 474 | "startMileage": td.get("startMileage", None), 475 | "traveltime": td.get("traveltime", None), 476 | "tripID": td.get("tripID", None), 477 | "zeroEmissionDistance": td.get("zeroEmissionDistance", None), 478 | } 479 | return attr 480 | 481 | 482 | class LastUpdate(Instrument): 483 | def __init__(self): 484 | super().__init__( 485 | component="sensor", 486 | attr="last_update_time", 487 | name="Last Update", 488 | icon="mdi:update", 489 | ) 490 | self.device_class = SensorDeviceClass.TIMESTAMP 491 | self.unit = None 492 | self.state_class = None 493 | self.entity_category = None 494 | self.extra_state_attributes = None 495 | 496 | @property 497 | def is_mutable(self): 498 | return False 499 | 500 | @property 501 | def str_state(self): 502 | ts = super().state 503 | return ts.astimezone(tz=None).isoformat() if ts else None 504 | 505 | @property 506 | def state(self): 507 | return super().state 508 | 509 | 510 | def create_instruments(): 511 | return [ 512 | Position(), 513 | LastUpdate(), 514 | TripData(attr="shortterm_current", name="ShortTerm Trip Data"), 515 | TripData(attr="shortterm_reset", name="ShortTerm Trip User Reset"), 516 | TripData(attr="longterm_current", name="LongTerm Trip Data"), 517 | TripData(attr="longterm_reset", name="LongTerm Trip User Reset"), 518 | Lock(), 519 | Preheater(), 520 | Climate(), 521 | Sensor( 522 | attr="model", 523 | name="Model", 524 | icon="mdi:car-info", 525 | entity_category=EntityCategory.DIAGNOSTIC, 526 | ), 527 | Sensor( 528 | attr="mileage", 529 | name="Mileage", 530 | icon="mdi:counter", 531 | unit=UnitOfLength.KILOMETERS, 532 | state_class=SensorStateClass.TOTAL_INCREASING, 533 | device_class=SensorDeviceClass.DISTANCE, 534 | entity_category=EntityCategory.DIAGNOSTIC, 535 | ), 536 | Sensor( 537 | attr="service_adblue_distance", 538 | name="AdBlue range", 539 | icon="mdi:map-marker-distance", 540 | unit=UnitOfLength.KILOMETERS, 541 | device_class=SensorDeviceClass.DISTANCE, 542 | ), 543 | Sensor( 544 | attr="range", 545 | name="Range", 546 | icon="mdi:map-marker-distance", 547 | unit=UnitOfLength.KILOMETERS, 548 | device_class=SensorDeviceClass.DISTANCE, 549 | ), 550 | Sensor( 551 | attr="hybrid_range", 552 | name="hybrid Range", 553 | icon="mdi:map-marker-distance", 554 | unit=UnitOfLength.KILOMETERS, 555 | device_class=SensorDeviceClass.DISTANCE, 556 | ), 557 | Sensor( 558 | attr="service_inspection_time", 559 | name="Service inspection time", 560 | icon="mdi:room-service-outline", 561 | unit=UnitOfTime.DAYS, 562 | entity_category=EntityCategory.DIAGNOSTIC, 563 | ), 564 | Sensor( 565 | attr="service_inspection_distance", 566 | name="Service inspection distance", 567 | icon="mdi:room-service-outline", 568 | unit=UnitOfLength.KILOMETERS, 569 | device_class=SensorDeviceClass.DISTANCE, 570 | entity_category=EntityCategory.DIAGNOSTIC, 571 | ), 572 | Sensor( 573 | attr="oil_change_time", 574 | name="Oil change time", 575 | icon="mdi:oil", 576 | unit=UnitOfTime.DAYS, 577 | entity_category=EntityCategory.DIAGNOSTIC, 578 | ), 579 | Sensor( 580 | attr="oil_change_distance", 581 | name="Oil change distance", 582 | icon="mdi:oil", 583 | unit=UnitOfLength.KILOMETERS, 584 | device_class=SensorDeviceClass.DISTANCE, 585 | entity_category=EntityCategory.DIAGNOSTIC, 586 | ), 587 | Sensor( 588 | attr="oil_level", 589 | name="Oil level", 590 | icon="mdi:oil", 591 | unit=PERCENTAGE, 592 | ), 593 | Sensor( 594 | attr="charging_state", 595 | name="Charging state", 596 | icon="mdi:car-battery", 597 | ), 598 | Sensor( 599 | attr="charging_mode", 600 | name="Charging mode", 601 | ), 602 | Sensor( 603 | attr="energy_flow", 604 | name="Energy flow", 605 | ), 606 | Sensor( 607 | attr="max_charge_current", 608 | name="Max charge current", 609 | icon="mdi:current-ac", 610 | unit=UnitOfElectricCurrent.AMPERE, 611 | device_class=SensorDeviceClass.CURRENT, 612 | ), 613 | Sensor( 614 | attr="primary_engine_type", 615 | name="Primary engine type", 616 | icon="mdi:engine", 617 | entity_category=EntityCategory.DIAGNOSTIC, 618 | ), 619 | Sensor( 620 | attr="secondary_engine_type", 621 | name="Secondary engine type", 622 | icon="mdi:engine", 623 | entity_category=EntityCategory.DIAGNOSTIC, 624 | ), 625 | Sensor( 626 | attr="primary_engine_range", 627 | name="Primary engine range", 628 | icon="mdi:map-marker-distance", 629 | unit=UnitOfLength.KILOMETERS, 630 | device_class=SensorDeviceClass.DISTANCE, 631 | ), 632 | Sensor( 633 | attr="secondary_engine_range", 634 | name="Secondary engine range", 635 | icon="mdi:map-marker-distance", 636 | unit=UnitOfLength.KILOMETERS, 637 | device_class=SensorDeviceClass.DISTANCE, 638 | ), 639 | Sensor( 640 | attr="primary_engine_range_percent", 641 | name="Primary engine Percent", 642 | icon="mdi:gauge", 643 | unit=PERCENTAGE, 644 | ), 645 | Sensor( 646 | attr="car_type", 647 | name="Car Type", 648 | icon="mdi:car-info", 649 | entity_category=EntityCategory.DIAGNOSTIC, 650 | ), 651 | Sensor( 652 | attr="secondary_engine_range_percent", 653 | name="Secondary engine Percent", 654 | icon="mdi:gauge", 655 | unit=PERCENTAGE, 656 | ), 657 | Sensor( 658 | attr="charging_power", 659 | name="Charging power", 660 | icon="mdi:flash", 661 | unit=UnitOfPower.KILO_WATT, 662 | device_class=SensorDeviceClass.POWER, 663 | ), 664 | Sensor( 665 | attr="actual_charge_rate", 666 | name="Charging rate", 667 | icon="mdi:electron-framework", 668 | ), 669 | Sensor( 670 | attr="tank_level", 671 | name="Tank level", 672 | icon="mdi:gauge", 673 | unit=PERCENTAGE, 674 | ), 675 | Sensor( 676 | attr="state_of_charge", 677 | name="State of charge", 678 | icon="mdi:ev-station", 679 | unit=PERCENTAGE, 680 | ), 681 | Sensor( 682 | attr="remaining_charging_time", 683 | name="Remaining charge time", 684 | icon="mdi:battery-charging", 685 | ), 686 | Sensor( 687 | attr="charging_complete_time", 688 | name="Charging Complete Time", 689 | icon="mdi:battery-charging", 690 | device_class=SensorDeviceClass.TIMESTAMP, 691 | ), 692 | Sensor( 693 | attr="target_state_of_charge", 694 | name="Target State of charge", 695 | icon="mdi:ev-station", 696 | unit=PERCENTAGE, 697 | ), 698 | BinarySensor( 699 | attr="plug_state", 700 | name="Plug state", 701 | icon="mdi:ev-plug-type1", 702 | device_class=BinarySensorDeviceClass.PLUG, 703 | ), 704 | BinarySensor( 705 | attr="plug_lock_state", 706 | name="Plug Lock state", 707 | icon="mdi:ev-plug-type1", 708 | device_class=BinarySensorDeviceClass.LOCK, 709 | ), 710 | Sensor( 711 | attr="external_power", 712 | name="External Power", 713 | icon="mdi:ev-station", 714 | ), 715 | Sensor( 716 | attr="plug_led_color", 717 | name="Plug LED Color", 718 | icon="mdi:ev-plug-type1", 719 | entity_category=EntityCategory.DIAGNOSTIC, 720 | ), 721 | Sensor( 722 | attr="doors_trunk_status", 723 | name="Doors/trunk state", 724 | icon="mdi:car-door", 725 | ), 726 | Sensor( 727 | attr="climatisation_state", 728 | name="Climatisation state", 729 | icon="mdi:air-conditioner", 730 | ), 731 | Sensor( 732 | attr="outdoor_temperature", 733 | name="Outdoor Temperature", 734 | icon="mdi:temperature-celsius", 735 | unit=UnitOfTemperature.CELSIUS, 736 | device_class=SensorDeviceClass.TEMPERATURE, 737 | ), 738 | Sensor( 739 | attr="park_time", 740 | name="Park Time", 741 | icon="mdi:car-clock", 742 | device_class=SensorDeviceClass.TIMESTAMP, 743 | ), 744 | Sensor( 745 | attr="remaining_climatisation_time", 746 | name="Remaining Climatisation Time", 747 | icon="mdi:fan-clock", 748 | unit=UnitOfTime.MINUTES, 749 | ), 750 | BinarySensor( 751 | attr="glass_surface_heating", 752 | name="Glass Surface Heating", 753 | icon="mdi:car-defrost-front", 754 | device_class=BinarySensorDeviceClass.RUNNING, 755 | ), 756 | Sensor( 757 | attr="preheater_duration", 758 | name="Preheater runtime", 759 | icon="mdi:clock", 760 | unit=UnitOfTime.MINUTES, 761 | ), 762 | Sensor( 763 | attr="preheater_remaining", 764 | name="Preheater remaining", 765 | icon="mdi:clock", 766 | unit=UnitOfTime.MINUTES, 767 | ), 768 | BinarySensor( 769 | attr="sun_roof", 770 | name="Sun roof", 771 | device_class=BinarySensorDeviceClass.WINDOW, 772 | ), 773 | BinarySensor( 774 | attr="roof_cover", 775 | name="Roof Cover", 776 | device_class=BinarySensorDeviceClass.WINDOW, 777 | ), 778 | BinarySensor( 779 | attr="parking_light", 780 | name="Parking light", 781 | device_class=BinarySensorDeviceClass.SAFETY, 782 | icon="mdi:lightbulb", 783 | entity_category=EntityCategory.DIAGNOSTIC, 784 | ), 785 | BinarySensor( 786 | attr="any_window_open", 787 | name="Windows", 788 | device_class=BinarySensorDeviceClass.WINDOW, 789 | ), 790 | BinarySensor( 791 | attr="any_door_unlocked", 792 | name="Doors lock", 793 | device_class=BinarySensorDeviceClass.LOCK, 794 | ), 795 | BinarySensor( 796 | attr="any_door_open", 797 | name="Doors", 798 | device_class=BinarySensorDeviceClass.DOOR, 799 | ), 800 | BinarySensor( 801 | attr="trunk_unlocked", 802 | name="Trunk lock", 803 | device_class=BinarySensorDeviceClass.LOCK, 804 | ), 805 | BinarySensor( 806 | attr="trunk_open", 807 | name="Trunk", 808 | device_class=BinarySensorDeviceClass.DOOR, 809 | ), 810 | BinarySensor( 811 | attr="hood_open", 812 | name="Hood", 813 | device_class=BinarySensorDeviceClass.DOOR, 814 | ), 815 | BinarySensor( 816 | attr="left_front_door_open", 817 | name="Left front door", 818 | device_class=BinarySensorDeviceClass.DOOR, 819 | entity_category=EntityCategory.DIAGNOSTIC, 820 | ), 821 | BinarySensor( 822 | attr="right_front_door_open", 823 | name="Right front door", 824 | device_class=BinarySensorDeviceClass.DOOR, 825 | entity_category=EntityCategory.DIAGNOSTIC, 826 | ), 827 | BinarySensor( 828 | attr="left_rear_door_open", 829 | name="Left rear door", 830 | device_class=BinarySensorDeviceClass.DOOR, 831 | entity_category=EntityCategory.DIAGNOSTIC, 832 | ), 833 | BinarySensor( 834 | attr="right_rear_door_open", 835 | name="Right rear door", 836 | device_class=BinarySensorDeviceClass.DOOR, 837 | entity_category=EntityCategory.DIAGNOSTIC, 838 | ), 839 | BinarySensor( 840 | attr="left_front_window_open", 841 | name="Left front window", 842 | device_class=BinarySensorDeviceClass.WINDOW, 843 | entity_category=EntityCategory.DIAGNOSTIC, 844 | ), 845 | BinarySensor( 846 | attr="right_front_window_open", 847 | name="Right front window", 848 | device_class=BinarySensorDeviceClass.WINDOW, 849 | entity_category=EntityCategory.DIAGNOSTIC, 850 | ), 851 | BinarySensor( 852 | attr="left_rear_window_open", 853 | name="Left rear window", 854 | device_class=BinarySensorDeviceClass.WINDOW, 855 | entity_category=EntityCategory.DIAGNOSTIC, 856 | ), 857 | BinarySensor( 858 | attr="right_rear_window_open", 859 | name="Right rear window", 860 | device_class=BinarySensorDeviceClass.WINDOW, 861 | entity_category=EntityCategory.DIAGNOSTIC, 862 | ), 863 | BinarySensor( 864 | attr="braking_status", 865 | name="Braking status", 866 | device_class=BinarySensorDeviceClass.SAFETY, 867 | icon="mdi:car-brake-abs", 868 | ), 869 | BinarySensor( 870 | attr="oil_level_binary", 871 | name="Oil Level Binary", 872 | icon="mdi:oil", 873 | device_class=BinarySensorDeviceClass.PROBLEM, 874 | entity_category=EntityCategory.DIAGNOSTIC, 875 | ), 876 | BinarySensor( 877 | attr="is_moving", 878 | name="Is moving", 879 | icon="mdi:motion-outline", 880 | device_class=BinarySensorDeviceClass.MOVING, 881 | entity_category=EntityCategory.DIAGNOSTIC, 882 | ), 883 | ] 884 | 885 | class Dashboard: 886 | def __init__(self, connection, vehicle, **config): 887 | self.instruments = [ 888 | instrument 889 | for instrument in create_instruments() 890 | if instrument.setup(connection, vehicle, **config) 891 | ] 892 | --------------------------------------------------------------------------------