├── .envrc ├── .gitattributes ├── custom_components ├── porscheconnect │ ├── strings.json │ ├── manifest.json │ ├── const.py │ ├── services.yaml │ ├── icons.json │ ├── lock.py │ ├── button.py │ ├── device_tracker.py │ ├── image.py │ ├── number.py │ ├── switch.py │ ├── services.py │ ├── translations │ │ ├── en.json │ │ └── sv.json │ ├── binary_sensor.py │ ├── __init__.py │ ├── config_flow.py │ └── sensor.py └── __init__.py ├── hacs.json ├── requirements_test.txt ├── .gitignore ├── requirements_dev.txt ├── .github ├── workflows │ ├── constraints.txt │ ├── release-drafter.yml │ ├── labeler.yml │ └── tests.yaml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── release-drafter.yml └── labels.yml ├── .pre-commit-config.yaml ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── setup.cfg ├── pyproject.toml ├── tests ├── const.py ├── test_binary_sensor.py ├── test_sensor.py ├── test_number.py ├── test_services.py ├── test_lock.py ├── __init__.py ├── test_switch.py ├── test_config_flow.py ├── test_init.py ├── conftest.py └── fixtures │ ├── taycan.json │ └── taycan_privacy.json ├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .cookiecutter.json ├── LICENSE ├── CONTRIBUTING.md └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | layout pyenv 3.12.2 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/strings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Porsche Connect" 3 | } 4 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Dummy init so that pytest works.""" 2 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements_dev.txt 2 | pytest-homeassistant-custom-component 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pythonenv* 3 | .python-version 4 | .coverage 5 | venv 6 | .venv 7 | .direnv 8 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pre-commit==4.3.0 2 | ruff>=0.8 3 | homeassistant 4 | pyporscheconnectapi 5 | pytest 6 | -------------------------------------------------------------------------------- /.github/workflows/constraints.txt: -------------------------------------------------------------------------------- 1 | pip==25.2 2 | pre-commit==4.3.0 3 | black==25.9.0 4 | flake8==7.3.0 5 | reorder-python-imports==3.15.0 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.8.3 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.pythonPath": "venv/bin/python", 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = -qq --cov=custom_components.porscheconnect 3 | console_output_style = count 4 | 5 | [coverage:run] 6 | branch = False 7 | 8 | [coverage:report] 9 | show_missing = true 10 | fail_under = 100 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | exclude = [ "tests/*.py" ] 3 | 4 | [tool.ruff.lint] 5 | select = [ "ALL" ] 6 | ignore = [ "ANN001", "ANN201", "ANN202", "ANN204", "ARG001", "ARG002", "TC001", "TC002", "TC003", "COM812", "ISC001", "PLC0415" ] 7 | -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Constants for Porsche Connect tests.""" 2 | from homeassistant.const import CONF_EMAIL 3 | from homeassistant.const import CONF_PASSWORD 4 | 5 | 6 | MOCK_CONFIG = {CONF_EMAIL: "test_username", CONF_PASSWORD: "test_password"} 7 | -------------------------------------------------------------------------------- /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | 3 | logger: 4 | default: info 5 | logs: 6 | custom_components.porscheconnect: debug 7 | # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 8 | # debugpy: 9 | -------------------------------------------------------------------------------- /.cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "_template": "https://github.com/oncleben31/cookiecutter-homeassistant-custom-component", 3 | "class_name_prefix": "PorscheConnect", 4 | "domain_name": "porscheconnect", 5 | "friendly_name": "Porsche Connect", 6 | "github_user": "CJNE", 7 | "project_name": "ha-porscheconnect", 8 | "test_suite": "yes", 9 | "version": "0.0.5" 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: daily 11 | - package-ecosystem: pip 12 | directory: "/" 13 | schedule: 14 | interval: daily 15 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Draft a release note 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | jobs: 8 | draft_release: 9 | name: Release Drafter 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Run release-drafter 13 | uses: release-drafter/release-drafter@v6.1.0 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Manage labels 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | labeler: 11 | name: Labeler 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out the repository 15 | uses: actions/checkout@v5 16 | 17 | - name: Run Labeler 18 | uses: crazy-max/ghaction-github-labeler@v5.3.0 19 | with: 20 | skip-delete: true 21 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "porscheconnect", 3 | "name": "Porsche Connect", 4 | "codeowners": ["@cjne"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/CJNE/ha-porscheconnect", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/CJNE/ha-porscheconnect/issues", 10 | "requirements": ["pyporscheconnectapi==0.2.5"], 11 | "version": "0.1.7" 12 | } 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the solution you'd like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Describe alternatives you've considered** 13 | A clear and concise description of any alternative solutions or features you've considered. 14 | 15 | **Additional context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /tests/test_binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Test porscheconnect binary sensor.""" 2 | import pytest 3 | from homeassistant.const import STATE_ON 4 | from homeassistant.core import HomeAssistant 5 | 6 | from . import setup_mock_porscheconnect_config_entry 7 | 8 | TEST_PARKING_BREAK_ENTITY_ID = "binary_sensor.taycan_turbo_s_parking_break" 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_pargking_break_sensor(hass: HomeAssistant, mock_connection) -> None: 13 | """Verify device information includes expected details.""" 14 | 15 | await setup_mock_porscheconnect_config_entry(hass) 16 | 17 | entity_state = hass.states.get(TEST_PARKING_BREAK_ENTITY_ID) 18 | assert entity_state.state == STATE_ON 19 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Porsche Connect integration.""" 2 | 3 | DOMAIN = "porscheconnect" 4 | DEFAULT_SCAN_INTERVAL = 1920 5 | 6 | NAME = "porscheconnect" 7 | DOMAIN_DATA = f"{DOMAIN}_data" 8 | VERSION = "0.1.0" 9 | ISSUE_URL = "https://github.com/cjne/ha-porscheconnect/issues" 10 | 11 | PLATFORMS = [ 12 | "sensor", 13 | "binary_sensor", 14 | "device_tracker", 15 | "number", 16 | "switch", 17 | "button", 18 | "lock", 19 | "image", 20 | ] 21 | 22 | STARTUP_MESSAGE = f""" 23 | ------------------------------------------------------------------- 24 | {NAME} 25 | Version: {VERSION} 26 | This is a custom integration! 27 | If you have any issues with this you need to open an issue here: 28 | {ISSUE_URL} 29 | ------------------------------------------------------------------- 30 | """ 31 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: ":boom: Breaking Changes" 3 | label: "breaking" 4 | - title: ":rocket: Features" 5 | label: "enhancement" 6 | - title: ":fire: Removals and Deprecations" 7 | label: "removal" 8 | - title: ":beetle: Fixes" 9 | label: "bug" 10 | - title: ":racehorse: Performance" 11 | label: "performance" 12 | - title: ":rotating_light: Testing" 13 | label: "testing" 14 | - title: ":construction_worker: Continuous Integration" 15 | label: "ci" 16 | - title: ":books: Documentation" 17 | label: "documentation" 18 | - title: ":hammer: Refactoring" 19 | label: "refactoring" 20 | - title: ":lipstick: Style" 21 | label: "style" 22 | - title: ":package: Dependencies" 23 | labels: 24 | - "dependencies" 25 | - "build" 26 | template: | 27 | ## Changes 28 | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/services.yaml: -------------------------------------------------------------------------------- 1 | climatisation_start: 2 | fields: 3 | vehicle: 4 | required: true 5 | selector: 6 | device: 7 | integration: porscheconnect 8 | temperature: 9 | example: "21" 10 | required: false 11 | default: 21 12 | selector: 13 | number: 14 | min: 15 15 | max: 25 16 | step: 0.5 17 | unit_of_measurement: °C 18 | front_left: 19 | example: true 20 | required: false 21 | default: false 22 | selector: 23 | boolean: 24 | front_right: 25 | example: false 26 | required: false 27 | default: false 28 | selector: 29 | boolean: 30 | rear_left: 31 | example: false 32 | required: false 33 | default: false 34 | selector: 35 | boolean: 36 | rear_right: 37 | example: false 38 | required: false 39 | default: false 40 | selector: 41 | boolean: 42 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "binary_sensor": { 4 | "parking_light": { 5 | "default": "mdi:car-parking-lights" 6 | }, 7 | "tire_pressure_status": { 8 | "default": "mdi:car-tire-alert" 9 | } 10 | }, 11 | "button": { 12 | "flash_indicators": { 13 | "default": "mdi:unfold-more-vertical" 14 | }, 15 | "honk_and_flash_indicators": { 16 | "default": "mdi:bugle" 17 | }, 18 | "get_current_overview": { 19 | "default": "mdi:reload" 20 | } 21 | }, 22 | "number": { 23 | "target_soc": { 24 | "default": "mdi:battery-charging-medium" 25 | } 26 | }, 27 | "switch": { 28 | "climatise": { 29 | "default": "mdi:fan" 30 | }, 31 | "direct_charging": { 32 | "default": "mdi:ev-station", 33 | "state": { 34 | "on": "mdi:ev-station", 35 | "off": "mdi:ev-station" 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "image": "ludeeus/container:integration-debian", 4 | "name": "Porsche Connect integration development", 5 | "context": "..", 6 | "appPort": ["9123:8123"], 7 | "postCreateCommand": "container install", 8 | "extensions": [ 9 | "ms-python.python", 10 | "github.vscode-pull-request-github", 11 | "ryanluker.vscode-coverage-gutters", 12 | "ms-python.vscode-pylance" 13 | ], 14 | "settings": { 15 | "files.eol": "\n", 16 | "editor.tabSize": 4, 17 | "terminal.integrated.shell.linux": "/bin/bash", 18 | "python.pythonPath": "/usr/bin/python3", 19 | "python.analysis.autoSearchPaths": false, 20 | "python.linting.pylintEnabled": true, 21 | "python.linting.enabled": true, 22 | "python.formatting.provider": "black", 23 | "editor.formatOnPaste": false, 24 | "editor.formatOnSave": true, 25 | "editor.formatOnType": true, 26 | "files.trimTrailingWhitespace": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | --- 5 | 6 | 15 | 16 | ## Version of the custom_component 17 | 18 | 21 | 22 | ## Configuration 23 | 24 | ```yaml 25 | Add your logs here. 26 | ``` 27 | 28 | ## Describe the bug 29 | 30 | A clear and concise description of what the bug is. 31 | 32 | ## Debug log 33 | 34 | 35 | 36 | ```text 37 | 38 | Add your logs here. 39 | 40 | ``` 41 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Test porscheconnect sensor.""" 2 | import pytest 3 | from homeassistant.core import HomeAssistant 4 | 5 | from . import setup_mock_porscheconnect_config_entry 6 | 7 | TEST_MILEAGE_SENSOR_ENTITY_ID = "sensor.taycan_turbo_s_mileage" 8 | TEST_CHARGER_SENSOR_ENTITY_ID = "sensor.taycan_turbo_s_charger" 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_mileage_sensor(hass: HomeAssistant, mock_connection) -> None: 13 | """Verify device information includes expected details.""" 14 | 15 | await setup_mock_porscheconnect_config_entry(hass) 16 | 17 | entity_state = hass.states.get(TEST_MILEAGE_SENSOR_ENTITY_ID) 18 | assert entity_state 19 | assert entity_state.state == "13247" 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_charger_sensor(hass: HomeAssistant, mock_connection) -> None: 24 | """Verify device information includes expected details.""" 25 | 26 | await setup_mock_porscheconnect_config_entry(hass) 27 | 28 | entity_state = hass.states.get(TEST_CHARGER_SENSOR_ENTITY_ID) 29 | assert entity_state 30 | assert entity_state.state == "NOT_CHARGING" 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 CJNE 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 | -------------------------------------------------------------------------------- /tests/test_number.py: -------------------------------------------------------------------------------- 1 | """Test porscheconnect number.""" 2 | from unittest.mock import MagicMock 3 | 4 | import pytest 5 | from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN 6 | from homeassistant.components.number import SERVICE_SET_VALUE 7 | from homeassistant.const import ( 8 | ATTR_ENTITY_ID, 9 | ) 10 | from homeassistant.core import HomeAssistant 11 | 12 | from . import setup_mock_porscheconnect_config_entry 13 | 14 | TEST_CHARGING_LEVEL_NUMBER_ENTITY_ID = "number.taycan_turbo_s_charging_level_4" 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_number(hass: HomeAssistant, mock_set_charging_level: MagicMock) -> None: 19 | """Verify device information includes expected details.""" 20 | 21 | await setup_mock_porscheconnect_config_entry(hass) 22 | 23 | entity_state = hass.states.get(TEST_CHARGING_LEVEL_NUMBER_ENTITY_ID) 24 | assert entity_state 25 | assert entity_state.state == "25" 26 | await hass.services.async_call( 27 | NUMBER_DOMAIN, 28 | SERVICE_SET_VALUE, 29 | { 30 | ATTR_ENTITY_ID: TEST_CHARGING_LEVEL_NUMBER_ENTITY_ID, 31 | "value": "58", 32 | }, 33 | blocking=False, 34 | ) 35 | assert mock_set_charging_level.call_count == 0 36 | await hass.async_block_till_done() 37 | assert mock_set_charging_level.call_count == 1 38 | mock_set_charging_level.assert_called_with( 39 | "WPTAYCAN", None, 4, minimumChargeLevel=58.0 40 | ) 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: [ push, pull_request ] 4 | 5 | env: 6 | DEFAULT_PYTHON: 3.13 7 | 8 | jobs: 9 | ruff: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v5 13 | - uses: astral-sh/ruff-action@v3 14 | 15 | hacs: 16 | runs-on: "ubuntu-latest" 17 | name: HACS 18 | steps: 19 | - name: Check out the repository 20 | uses: "actions/checkout@v5" 21 | 22 | - name: HACS validation 23 | uses: "hacs/action@22.5.0" 24 | with: 25 | category: "integration" 26 | ignore: brands 27 | 28 | hassfest: 29 | runs-on: "ubuntu-latest" 30 | name: Hassfest 31 | steps: 32 | - name: Check out the repository 33 | uses: "actions/checkout@v5" 34 | 35 | - name: Hassfest validation 36 | uses: "home-assistant/actions/hassfest@master" 37 | 38 | ## Tests disabled for now 39 | 40 | # tests: 41 | # runs-on: "ubuntu-latest" 42 | # name: Run tests 43 | # steps: 44 | # - name: Check out code from GitHub 45 | # uses: "actions/checkout@v5" 46 | # - name: Setup Python ${{ env.DEFAULT_PYTHON }} 47 | # uses: "actions/setup-python@v5.3.0" 48 | # with: 49 | # python-version: ${{ env.DEFAULT_PYTHON }} 50 | # - name: Install requirements 51 | # run: | 52 | # pip install --constraint=.github/workflows/constraints.txt pip 53 | # pip install -r requirements_test.txt 54 | # - name: Tests suite 55 | # run: | 56 | # pytest \ 57 | # --timeout=9 \ 58 | # --durations=10 \ 59 | # -n auto \ 60 | # -p no:sugar \ 61 | # tests 62 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: bfd4f2 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: build 14 | description: Build System and Dependencies 15 | color: bfdadc 16 | - name: ci 17 | description: Continuous Integration 18 | color: 4a97d6 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: 0366d6 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: 0075ca 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | - name: enhancement 29 | description: New feature or request 30 | color: a2eeef 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: 7057ff 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: e4e669 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: 2b67c6 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | - name: refactoring 53 | description: Refactoring 54 | color: ef67c4 55 | - name: removal 56 | description: Removals and Deprecations 57 | color: 9ae7ea 58 | - name: style 59 | description: Style 60 | color: c120e5 61 | - name: testing 62 | description: Testing 63 | color: b1fc6f 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: ffffff 67 | -------------------------------------------------------------------------------- /tests/test_services.py: -------------------------------------------------------------------------------- 1 | """Test porscheconnect number.""" 2 | from unittest.mock import MagicMock 3 | 4 | import pytest 5 | from custom_components.porscheconnect.const import DOMAIN as PORSCHE_DOMAIN 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers import device_registry as dr 8 | 9 | from . import setup_mock_porscheconnect_config_entry 10 | 11 | SERVICE_HONK_AND_FLASH = "honk_and_flash" 12 | SERVICE_FLASH = "flash" 13 | ATTR_VEHICLE = "vehicle" 14 | 15 | 16 | def get_device_id(hass: HomeAssistant) -> str: 17 | """Get device_id.""" 18 | device_registry = dr.async_get(hass) 19 | identifiers = {(PORSCHE_DOMAIN, "WPTAYCAN")} 20 | device = device_registry.async_get_device(identifiers) 21 | return device.id 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_honk_and_flash( 26 | hass: HomeAssistant, mock_honk_and_flash: MagicMock 27 | ) -> None: 28 | """Verify device information includes expected details.""" 29 | 30 | await setup_mock_porscheconnect_config_entry(hass) 31 | data = { 32 | ATTR_VEHICLE: get_device_id(hass), 33 | } 34 | 35 | await hass.services.async_call( 36 | PORSCHE_DOMAIN, 37 | SERVICE_HONK_AND_FLASH, 38 | data, 39 | blocking=False, 40 | ) 41 | assert mock_honk_and_flash.call_count == 0 42 | await hass.async_block_till_done() 43 | assert mock_honk_and_flash.call_count == 1 44 | mock_honk_and_flash.assert_called_with("WPTAYCAN", True) 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_flash(hass: HomeAssistant, mock_flash: MagicMock) -> None: 49 | """Verify device information includes expected details.""" 50 | 51 | await setup_mock_porscheconnect_config_entry(hass) 52 | data = { 53 | ATTR_VEHICLE: get_device_id(hass), 54 | } 55 | 56 | await hass.services.async_call( 57 | PORSCHE_DOMAIN, 58 | SERVICE_FLASH, 59 | data, 60 | blocking=False, 61 | ) 62 | assert mock_flash.call_count == 0 63 | await hass.async_block_till_done() 64 | assert mock_flash.call_count == 1 65 | mock_flash.assert_called_with("WPTAYCAN", True) 66 | -------------------------------------------------------------------------------- /tests/test_lock.py: -------------------------------------------------------------------------------- 1 | """Test myenergi sensor.""" 2 | 3 | import pytest 4 | 5 | # from custom_components.porscheconnect import PinError 6 | from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN 7 | from homeassistant.const import ATTR_CODE 8 | from homeassistant.const import ( 9 | ATTR_ENTITY_ID, 10 | ) 11 | from homeassistant.const import SERVICE_LOCK 12 | from homeassistant.const import SERVICE_UNLOCK 13 | from homeassistant.components.lock.const import LockState 14 | from homeassistant.core import HomeAssistant 15 | 16 | from . import setup_mock_porscheconnect_config_entry 17 | 18 | TEST_DOOR_LOCK_ENTITY_ID = "lock.taycan_turbo_s_door_lock" 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_door_lock(hass: HomeAssistant, mock_lock_lock, mock_lock_unlock) -> None: 23 | """Verify device information includes expected details.""" 24 | 25 | await setup_mock_porscheconnect_config_entry(hass) 26 | 27 | entity_state = hass.states.get(TEST_DOOR_LOCK_ENTITY_ID) 28 | assert entity_state 29 | assert entity_state.state == LockState.LOCKED 30 | await hass.services.async_call( 31 | LOCK_DOMAIN, 32 | SERVICE_UNLOCK, 33 | { 34 | ATTR_ENTITY_ID: TEST_DOOR_LOCK_ENTITY_ID, 35 | ATTR_CODE: 1234, 36 | }, 37 | blocking=False, 38 | ) 39 | assert mock_lock_unlock.call_count == 0 40 | await hass.async_block_till_done() 41 | mock_lock_unlock.assert_called_with("WPTAYCAN", "1234", True) 42 | assert mock_lock_unlock.call_count == 1 43 | 44 | await hass.services.async_call( 45 | LOCK_DOMAIN, 46 | SERVICE_LOCK, 47 | { 48 | ATTR_ENTITY_ID: TEST_DOOR_LOCK_ENTITY_ID, 49 | }, 50 | blocking=False, 51 | ) 52 | assert mock_lock_lock.call_count == 0 53 | await hass.async_block_till_done() 54 | mock_lock_lock.assert_called_with("WPTAYCAN", True) 55 | assert mock_lock_lock.call_count == 1 56 | 57 | 58 | # @pytest.mark.asyncio 59 | # async def test_door_unlock_without_pin(hass: HomeAssistant, mock_lock_unlock) -> None: 60 | # """Verify device information includes expected details.""" 61 | # 62 | # with pytest.raises(PinError): 63 | # await setup_mock_porscheconnect_config_entry(hass) 64 | # entity_state = hass.states.get(TEST_DOOR_LOCK_ENTITY_ID) 65 | # assert entity_state 66 | # assert entity_state.state == LockState.LOCKED 67 | # await hass.services.async_call( 68 | # LOCK_DOMAIN, 69 | # SERVICE_UNLOCK, 70 | # { 71 | # ATTR_ENTITY_ID: TEST_DOOR_LOCK_ENTITY_ID, 72 | # }, 73 | # blocking=True, 74 | # ) 75 | # await hass.async_block_till_done() 76 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for Porsche Connect integration.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | from typing import Any 6 | from unittest.mock import Mock 7 | from unittest.mock import patch 8 | 9 | from custom_components.porscheconnect import DOMAIN 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from pytest_homeassistant_custom_component.common import MockConfigEntry 13 | 14 | from .const import MOCK_CONFIG 15 | 16 | TEST_CONFIG_ENTRY_ID = "77889900ac" 17 | 18 | 19 | def load_fixture_json(name): 20 | with open(f"tests/fixtures/{name}.json") as json_file: 21 | data = json.load(json_file) 22 | return data 23 | 24 | 25 | def create_mock_porscheconnect_config_entry( 26 | hass: HomeAssistant, 27 | data: dict[str, Any] | None = None, 28 | options: dict[str, Any] | None = None, 29 | ) -> ConfigEntry: 30 | """Add a test config entry.""" 31 | config_entry: MockConfigEntry = MockConfigEntry( 32 | entry_id=TEST_CONFIG_ENTRY_ID, 33 | domain=DOMAIN, 34 | data=data or MOCK_CONFIG, 35 | title="", 36 | options=options or {}, 37 | ) 38 | config_entry.add_to_hass(hass) 39 | return config_entry 40 | 41 | 42 | async def setup_mock_porscheconnect_config_entry( 43 | hass: HomeAssistant, 44 | data: dict[str, Any] | None = None, 45 | config_entry: ConfigEntry | None = None, 46 | client: Mock | None = None, 47 | ) -> ConfigEntry: 48 | # client_data = "taycan" 49 | # if data is not None: 50 | # client_data = data.get("client_data", "taycan") 51 | """Add a mock porscheconnect config entry to hass.""" 52 | config_entry = config_entry or create_mock_porscheconnect_config_entry(hass, data) 53 | 54 | fixture_name = "taycan" 55 | fixture_data = load_fixture_json(fixture_name) 56 | print(f"Using mock connedion fixture {fixture_name}") 57 | 58 | async def mock_get(self, url, params=None): 59 | print(f"Mock connection GET {url}") 60 | print(params) 61 | ret = fixture_data["GET"].get(url, {}) 62 | print(ret) 63 | return ret 64 | 65 | async def mock_post(self, url, data=None, json=None): 66 | print(f"POST {url}") 67 | print(data) 68 | print(json) 69 | return {} 70 | 71 | async def mock_tokens(self, application, wasExpired=False): 72 | print(f"Request mock token {application}") 73 | return {} 74 | 75 | with patch("pyporscheconnectapi.client.Connection.get", mock_get), patch( 76 | "pyporscheconnectapi.client.Connection.post", mock_post 77 | ), patch("pyporscheconnectapi.client.Connection._requestToken", mock_tokens): 78 | await hass.config_entries.async_setup(config_entry.entry_id) 79 | await hass.async_block_till_done() 80 | return config_entry 81 | -------------------------------------------------------------------------------- /tests/test_switch.py: -------------------------------------------------------------------------------- 1 | """Test myenergi sensor.""" 2 | from unittest.mock import MagicMock 3 | 4 | import pytest 5 | from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN 6 | from homeassistant.const import ( 7 | ATTR_ENTITY_ID, 8 | ) 9 | from homeassistant.const import SERVICE_TURN_OFF 10 | from homeassistant.const import SERVICE_TURN_ON 11 | from homeassistant.const import STATE_OFF 12 | from homeassistant.core import HomeAssistant 13 | 14 | from . import setup_mock_porscheconnect_config_entry 15 | 16 | TEST_CLIMATE_SWITCH_ENTITY_ID = "switch.taycan_turbo_s_climatisation" 17 | TEST_CHARGE_SWITCH_ENTITY_ID = "switch.taycan_turbo_s_direct_charge" 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_climate( 22 | hass: HomeAssistant, mock_set_climate_on: MagicMock, mock_set_climate_off: MagicMock 23 | ) -> None: 24 | """Verify device information includes expected details.""" 25 | 26 | await setup_mock_porscheconnect_config_entry(hass) 27 | 28 | entity_state = hass.states.get(TEST_CLIMATE_SWITCH_ENTITY_ID) 29 | assert entity_state 30 | assert entity_state.state == STATE_OFF 31 | await hass.services.async_call( 32 | SWITCH_DOMAIN, 33 | SERVICE_TURN_ON, 34 | { 35 | ATTR_ENTITY_ID: TEST_CLIMATE_SWITCH_ENTITY_ID, 36 | }, 37 | blocking=False, 38 | ) 39 | assert mock_set_climate_on.call_count == 0 40 | await hass.async_block_till_done() 41 | assert mock_set_climate_on.call_count == 1 42 | await hass.services.async_call( 43 | SWITCH_DOMAIN, 44 | SERVICE_TURN_OFF, 45 | { 46 | ATTR_ENTITY_ID: TEST_CLIMATE_SWITCH_ENTITY_ID, 47 | }, 48 | blocking=False, 49 | ) 50 | assert mock_set_climate_off.call_count == 0 51 | await hass.async_block_till_done() 52 | assert mock_set_climate_off.call_count == 1 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_directcharge( 57 | hass: HomeAssistant, mock_set_charge_on: MagicMock, mock_set_charge_off: MagicMock 58 | ) -> None: 59 | """Verify device information includes expected details.""" 60 | 61 | await setup_mock_porscheconnect_config_entry(hass) 62 | 63 | entity_state = hass.states.get(TEST_CHARGE_SWITCH_ENTITY_ID) 64 | assert entity_state 65 | assert entity_state.state == STATE_OFF 66 | await hass.services.async_call( 67 | SWITCH_DOMAIN, 68 | SERVICE_TURN_ON, 69 | { 70 | ATTR_ENTITY_ID: TEST_CHARGE_SWITCH_ENTITY_ID, 71 | }, 72 | blocking=False, 73 | ) 74 | assert mock_set_charge_on.call_count == 0 75 | await hass.async_block_till_done() 76 | assert mock_set_charge_on.call_count == 1 77 | await hass.services.async_call( 78 | SWITCH_DOMAIN, 79 | SERVICE_TURN_OFF, 80 | { 81 | ATTR_ENTITY_ID: TEST_CHARGE_SWITCH_ENTITY_ID, 82 | }, 83 | blocking=False, 84 | ) 85 | assert mock_set_charge_off.call_count == 0 86 | await hass.async_block_till_done() 87 | assert mock_set_charge_off.call_count == 1 88 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/lock.py: -------------------------------------------------------------------------------- 1 | """Support for Porsche lock entity.""" 2 | 3 | import logging 4 | 5 | from homeassistant.components.lock import CONF_DEFAULT_CODE, LockEntity 6 | from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant, callback 9 | from homeassistant.exceptions import HomeAssistantError 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | from pyporscheconnectapi.exceptions import PorscheExceptionError 12 | from pyporscheconnectapi.vehicle import PorscheVehicle 13 | 14 | from . import ( 15 | PorscheBaseEntity, 16 | PorscheConnectDataUpdateCoordinator, 17 | ) 18 | from .const import DOMAIN 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | async def async_setup_entry( 24 | hass: HomeAssistant, 25 | config_entry: ConfigEntry, 26 | async_add_entities: AddEntitiesCallback, 27 | ) -> None: 28 | """Set up the Porsche Connect lock entity from config entry.""" 29 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[DOMAIN][ 30 | config_entry.entry_id 31 | ] 32 | 33 | async_add_entities( 34 | PorscheLock(coordinator, vehicle) for vehicle in coordinator.vehicles 35 | ) 36 | 37 | 38 | class PorscheLock(PorscheBaseEntity, LockEntity): 39 | """Representation of a Porsche vehicle lock.""" 40 | 41 | _attr_translation_key = "lock" 42 | 43 | def __init__( 44 | self, 45 | coordinator: PorscheConnectDataUpdateCoordinator, 46 | vehicle: PorscheVehicle, 47 | ) -> None: 48 | """Initialize the lock.""" 49 | super().__init__(coordinator, vehicle) 50 | 51 | self._attr_unique_id = f'{vehicle.data["name"]}-lock' 52 | self.door_lock_state_available = vehicle.has_remote_services 53 | 54 | async def async_lock(self) -> None: 55 | """Lock the vehicle.""" 56 | try: 57 | await self.vehicle.remote_services.lock_vehicle() 58 | except PorscheExceptionError as ex: 59 | self._attr_is_locked = None 60 | self.async_write_ha_state() 61 | raise HomeAssistantError(ex) from ex 62 | finally: 63 | self.coordinator.async_update_listeners() 64 | 65 | async def async_unlock(self, **kwargs: dict) -> None: 66 | """Unlock the vehicle.""" 67 | pin = kwargs.get("code") 68 | 69 | if pin is None: 70 | lock_options = self.registry_entry.options.get(LOCK_DOMAIN) 71 | pin = lock_options.get(CONF_DEFAULT_CODE) 72 | 73 | if pin: 74 | try: 75 | await self.vehicle.remote_services.unlock_vehicle(pin) 76 | except PorscheExceptionError as ex: 77 | self._attr_is_locked = None 78 | self.async_write_ha_state() 79 | raise HomeAssistantError(ex) from ex 80 | finally: 81 | self.coordinator.async_update_listeners() 82 | else: 83 | msg = "PIN code not provided." 84 | raise ValueError(msg) 85 | 86 | @callback 87 | def _handle_coordinator_update(self) -> None: 88 | """Handle updated data from the coordinator.""" 89 | _LOGGER.debug("Updating lock data of %s", self.vehicle.vin) 90 | self._attr_is_locked = self.vehicle.vehicle_locked 91 | 92 | super()._handle_coordinator_update() 93 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/button.py: -------------------------------------------------------------------------------- 1 | """Support for Porsche Connect button entities.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | from typing import Any 6 | 7 | from homeassistant.components.button import ButtonEntity, ButtonEntityDescription 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.exceptions import HomeAssistantError 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from pyporscheconnectapi.exceptions import PorscheExceptionError 13 | from pyporscheconnectapi.vehicle import PorscheVehicle 14 | 15 | from . import ( 16 | PorscheBaseEntity, 17 | PorscheConnectDataUpdateCoordinator, 18 | ) 19 | from .const import DOMAIN 20 | 21 | 22 | @dataclass(frozen=True, kw_only=True) 23 | class PorscheButtonEntityDescription(ButtonEntityDescription): 24 | """Class describing Porsche Connect button entities.""" 25 | 26 | remote_function: Callable[[PorscheVehicle], Coroutine[Any, Any, Any]] 27 | is_available: Callable[[PorscheVehicle], bool] = lambda v: v.has_remote_services 28 | 29 | 30 | BUTTON_TYPES: tuple[PorscheButtonEntityDescription, ...] = ( 31 | PorscheButtonEntityDescription( 32 | key="get_current_overview", 33 | translation_key="get_current_overview", 34 | remote_function=lambda v: v.get_current_overview(), 35 | ), 36 | PorscheButtonEntityDescription( 37 | key="flash_indicators", 38 | translation_key="flash_indicators", 39 | remote_function=lambda v: v.remote_services.flash_indicators(), 40 | ), 41 | PorscheButtonEntityDescription( 42 | key="honk_and_flash_indicators", 43 | translation_key="honk_and_flash_indicators", 44 | remote_function=lambda v: v.remote_services.honk_and_flash_indicators(), 45 | ), 46 | ) 47 | 48 | 49 | async def async_setup_entry( 50 | hass: HomeAssistant, 51 | config_entry: ConfigEntry, 52 | async_add_entities: AddEntitiesCallback, 53 | ) -> None: 54 | """Set up the Porsche buttons from config entry.""" 55 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[DOMAIN][ 56 | config_entry.entry_id 57 | ] 58 | 59 | entities: list[PorscheButton] = [] 60 | 61 | for vehicle in coordinator.vehicles: 62 | entities.extend( 63 | [ 64 | PorscheButton(coordinator, vehicle, description) 65 | for description in BUTTON_TYPES 66 | if description.is_available(vehicle) 67 | ], 68 | ) 69 | 70 | async_add_entities(entities) 71 | 72 | 73 | class PorscheButton(PorscheBaseEntity, ButtonEntity): 74 | """Representation of a Porsche Connect button.""" 75 | 76 | entity_description: PorscheButtonEntityDescription 77 | 78 | def __init__( 79 | self, 80 | coordinator: PorscheConnectDataUpdateCoordinator, 81 | vehicle: PorscheVehicle, 82 | description: PorscheButtonEntityDescription, 83 | ) -> None: 84 | """Initialize Porsche Connect button.""" 85 | super().__init__(coordinator, vehicle) 86 | self.entity_description = description 87 | self._attr_unique_id = f"{vehicle.vin}-{description.key}" 88 | 89 | async def async_press(self) -> None: 90 | """Press the button.""" 91 | try: 92 | await self.entity_description.remote_function(self.vehicle) 93 | except PorscheExceptionError as ex: 94 | raise HomeAssistantError(ex) from ex 95 | 96 | self.coordinator.async_update_listeners() 97 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/device_tracker.py: -------------------------------------------------------------------------------- 1 | """Device tracker for Porsche vehicles.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.components.device_tracker.config_entry import TrackerEntity 9 | from homeassistant.components.device_tracker.const import SourceType 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | from pyporscheconnectapi.vehicle import PorscheVehicle 14 | 15 | from . import ( 16 | PorscheBaseEntity, 17 | PorscheConnectDataUpdateCoordinator, 18 | ) 19 | from .const import DOMAIN 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_setup_entry( 25 | hass: HomeAssistant, 26 | config_entry: ConfigEntry, 27 | async_add_entities: AddEntitiesCallback, 28 | ) -> None: 29 | """Set up the device tracker from config entry.""" 30 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[DOMAIN][ 31 | config_entry.entry_id 32 | ] 33 | entities: list[PorscheDeviceTracker] = [] 34 | 35 | for vehicle in coordinator.vehicles: 36 | if not vehicle.privacy_mode: 37 | entities.append(PorscheDeviceTracker(coordinator, vehicle)) 38 | else: 39 | _LOGGER.info("Vehicle is in privacy mode with location tracking disabled") 40 | 41 | async_add_entities(entities) 42 | 43 | 44 | class PorscheDeviceTracker(PorscheBaseEntity, TrackerEntity): 45 | """Class describing Porsche Connect device tracker.""" 46 | 47 | def __init__( 48 | self, 49 | coordinator: PorscheConnectDataUpdateCoordinator, 50 | vehicle: PorscheVehicle, 51 | ) -> None: 52 | """Initialize the device tracker.""" 53 | super().__init__(coordinator, vehicle) 54 | 55 | self._attr_unique_id = vehicle.vin 56 | if ( 57 | "customName" in vehicle.data and 58 | vehicle.data["customName"] != vehicle.model_name 59 | and vehicle.data["customName"] is not None 60 | ): 61 | self._attr_name = vehicle.data["customName"] 62 | else: 63 | self._attr_name = None 64 | self._tracking_enabled = True 65 | self._attr_icon = "mdi:crosshairs-gps" 66 | 67 | @property 68 | def battery_level(self) -> int | None: 69 | """Return the battery level of the device. 70 | 71 | Percentage from 0-100. 72 | """ 73 | return self.vehicle.main_battery_level 74 | 75 | @property 76 | def extra_state_attributes(self) -> dict[str, Any]: 77 | """Return entity specific state attributes.""" 78 | data = {"updated_at": self.vehicle.location_updated_at} 79 | if self._tracking_enabled and self.vehicle.location[2] is not None: 80 | data["direction"] = float(self.vehicle.location[2]) 81 | return data 82 | 83 | @property 84 | def latitude(self) -> float | None: 85 | """Return latitude value of the device.""" 86 | if self._tracking_enabled and self.vehicle.location[0] is not None: 87 | return float(self.vehicle.location[0]) 88 | return None 89 | 90 | @property 91 | def longitude(self) -> float | None: 92 | """Return longitude value of the device.""" 93 | if self._tracking_enabled and self.vehicle.location[1] is not None: 94 | return float(self.vehicle.location[1]) 95 | return None 96 | 97 | @property 98 | def source_type(self) -> SourceType: 99 | """Return the source type, eg gps or router, of the device.""" 100 | return SourceType.GPS 101 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/image.py: -------------------------------------------------------------------------------- 1 | """Demo image platform.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | 7 | from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | from homeassistant.util import dt as dt_util 12 | 13 | from . import ( 14 | PorscheBaseEntity, 15 | PorscheConnectDataUpdateCoordinator, 16 | PorscheVehicle, 17 | ) 18 | from .const import DOMAIN 19 | 20 | CONTENT_TYPE = "image/png" 21 | 22 | 23 | @dataclass(frozen=True) 24 | class PorscheImageEntityDescription(ImageEntityDescription): 25 | """Describes a Porsche image entity.""" 26 | 27 | view: str = None 28 | 29 | 30 | IMAGE_TYPES: list[PorscheImageEntityDescription] = [ 31 | PorscheImageEntityDescription( 32 | name="Front view", 33 | key="front_view", 34 | translation_key="front_view", 35 | view="frontView", 36 | ), 37 | PorscheImageEntityDescription( 38 | name="Side view", 39 | key="side_view", 40 | translation_key="side_view", 41 | view="sideView", 42 | ), 43 | PorscheImageEntityDescription( 44 | name="Rear view", 45 | key="rear_view", 46 | translation_key="rear_view", 47 | view="rearView", 48 | ), 49 | PorscheImageEntityDescription( 50 | name="Rear top view", 51 | key="rear_top_view", 52 | translation_key="rear_top_view", 53 | view="rearTopView", 54 | ), 55 | PorscheImageEntityDescription( 56 | name="Top view", 57 | key="top_view", 58 | translation_key="top_view", 59 | view="topView", 60 | ), 61 | ] 62 | 63 | 64 | async def async_setup_entry( 65 | hass: HomeAssistant, 66 | config_entry: ConfigEntry, 67 | async_add_entities: AddEntitiesCallback, 68 | ) -> None: 69 | """Set up the Porsche Connect image entity from config entry.""" 70 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[DOMAIN][ 71 | config_entry.entry_id 72 | ] 73 | 74 | entities = [ 75 | PorscheImage(hass, coordinator, vehicle, description) 76 | for vehicle in coordinator.vehicles 77 | for description in IMAGE_TYPES 78 | if description.view in vehicle.picture_locations 79 | ] 80 | 81 | async_add_entities(entities) 82 | 83 | 84 | class PorscheImage(PorscheBaseEntity, ImageEntity): 85 | """Representation of an image entity.""" 86 | 87 | entity_description: PorscheImageEntityDescription 88 | 89 | def __init__( 90 | self, 91 | hass: HomeAssistant, 92 | coordinator: PorscheConnectDataUpdateCoordinator, 93 | vehicle: PorscheVehicle, 94 | description: PorscheImageEntityDescription, 95 | ) -> None: 96 | """Initialize the image entity.""" 97 | super().__init__(coordinator, vehicle) 98 | ImageEntity.__init__(self, hass) 99 | 100 | self.entity_description = description 101 | 102 | self._attr_content_type = CONTENT_TYPE 103 | self._attr_unique_id = f'{vehicle.data["name"]}-{description.key}' 104 | self._attr_image_url = vehicle.picture_locations[description.view] 105 | 106 | async def async_added_to_hass(self): 107 | """Set the update time.""" 108 | self._attr_image_last_updated = dt_util.utcnow() 109 | 110 | async def _async_load_image_from_url(self, url: str) -> Image | None: 111 | """Load an image by url.""" 112 | if response := await self._fetch_url(url): 113 | image_data = response.content 114 | return Image( 115 | content=image_data, 116 | content_type=CONTENT_TYPE, 117 | ) 118 | return None 119 | -------------------------------------------------------------------------------- /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 | ## Github is used for everything 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. Make sure your code lints (using black). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People _love_ thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) 48 | to make sure the code follows the style. 49 | 50 | Or use the `pre-commit` settings implemented in this repository 51 | (see deicated section below). 52 | 53 | ## Test your code modification 54 | 55 | This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). 56 | 57 | It comes with development environment in a container, easy to launch 58 | if you use Visual Studio Code. With this container you will have a stand alone 59 | Home Assistant instance running and already configured with the included 60 | [`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) 61 | file. 62 | 63 | You can use the `pre-commit` settings implemented in this repository to have 64 | linting tool checking your contributions (see deicated section below). 65 | 66 | You should also verify that existing [tests](./tests) are still working 67 | and you are encouraged to add new ones. 68 | You can run the tests using the following commands from the root folder: 69 | 70 | ```bash 71 | # Create a virtual environment 72 | python3 -m venv venv 73 | source venv/bin/activate 74 | # Install requirements 75 | pip install -r requirements_test.txt 76 | # Run tests and get a summary of successes/failures and code coverage 77 | pytest --durations=10 --cov-report term-missing --cov=custom_components.porscheconnect tests 78 | ``` 79 | 80 | If any of the tests fail, make the necessary changes to the tests as part of 81 | your changes to the integration. 82 | 83 | ## Pre-commit 84 | 85 | You can use the [pre-commit](https://pre-commit.com/) settings included in the 86 | repostory to have code style and linting checks. 87 | 88 | With `pre-commit` tool already installed, 89 | activate the settings of the repository: 90 | 91 | ```console 92 | $ pre-commit install 93 | ``` 94 | 95 | Now the pre-commit tests will be done every time you commit. 96 | 97 | You can run the tests on all repository file with the command: 98 | 99 | ```console 100 | $ pre-commit run --all-files 101 | ``` 102 | 103 | ## License 104 | 105 | By contributing, you agree that your contributions will be licensed under its MIT License. 106 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test Porsche Connect config flow.""" 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | from custom_components.porscheconnect.const import ( 6 | DOMAIN, 7 | ) 8 | from homeassistant import config_entries 9 | from homeassistant import data_entry_flow 10 | 11 | from .const import MOCK_CONFIG 12 | 13 | 14 | # from pytest_homeassistant_custom_component.common import MockConfigEntry 15 | 16 | 17 | # This fixture bypasses the actual setup of the integration 18 | # since we only want to test the config flow. We test the 19 | # actual functionality of the integration in other test modules. 20 | @pytest.fixture(autouse=True) 21 | def bypass_setup_fixture(): 22 | """Prevent setup.""" 23 | with patch( 24 | "custom_components.porscheconnect.async_setup", 25 | return_value=True, 26 | ), patch( 27 | "custom_components.porscheconnect.async_setup_entry", 28 | return_value=True, 29 | ): 30 | yield 31 | 32 | 33 | # Here we simiulate a successful config flow from the backend. 34 | # Note that we use the `bypass_get_data` fixture here because 35 | # we want the config flow validation to succeed during the test. 36 | @pytest.mark.asyncio 37 | async def test_successful_config_flow(hass, bypass_connection_connect): 38 | """Test a successful config flow.""" 39 | # Initialize a config flow 40 | result = await hass.config_entries.flow.async_init( 41 | DOMAIN, context={"source": config_entries.SOURCE_USER} 42 | ) 43 | 44 | # Check that the config flow shows the user form as the first step 45 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 46 | assert result["step_id"] == "user" 47 | 48 | # If a user were to enter `test_username` for username and `test_password` 49 | # for password, it would result in this function call 50 | result = await hass.config_entries.flow.async_configure( 51 | result["flow_id"], user_input=MOCK_CONFIG 52 | ) 53 | 54 | # Check that the config flow is complete and a new entry is created with 55 | # the input data 56 | assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY 57 | assert result["title"] == "test_username" 58 | assert result["data"] == MOCK_CONFIG 59 | assert result["result"] 60 | 61 | 62 | # In this case, we want to simulate a failure during the config flow. 63 | # We use the `error_on_get_data` mock instead of `bypass_get_data` 64 | # (note the function parameters) to raise an Exception during 65 | # validation of the input config. 66 | @pytest.mark.asyncio 67 | async def test_failed_config_flow_connect(hass, error_connection_connect): 68 | """Test a failed config flow due to credential validation failure.""" 69 | 70 | result = await hass.config_entries.flow.async_init( 71 | DOMAIN, context={"source": config_entries.SOURCE_USER} 72 | ) 73 | 74 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 75 | assert result["step_id"] == "user" 76 | 77 | result = await hass.config_entries.flow.async_configure( 78 | result["flow_id"], user_input=MOCK_CONFIG 79 | ) 80 | 81 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 82 | assert result["errors"] == {"base": "connect"} 83 | 84 | 85 | # In this case, we want to simulate a failure during the config flow. 86 | # We use the `error_on_get_data` mock instead of `bypass_get_data` 87 | # (note the function parameters) to raise an Exception during 88 | # validation of the input config. 89 | @pytest.mark.asyncio 90 | async def test_failed_config_flow_login(hass, error_connection_login): 91 | """Test a failed config flow due to credential validation failure.""" 92 | 93 | result = await hass.config_entries.flow.async_init( 94 | DOMAIN, context={"source": config_entries.SOURCE_USER} 95 | ) 96 | 97 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 98 | assert result["step_id"] == "user" 99 | 100 | result = await hass.config_entries.flow.async_configure( 101 | result["flow_id"], user_input=MOCK_CONFIG 102 | ) 103 | 104 | assert result["type"] == data_entry_flow.RESULT_TYPE_FORM 105 | assert result["errors"] == {"base": "auth"} 106 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/number.py: -------------------------------------------------------------------------------- 1 | """Support for the Porsche Connect number entities.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Callable, Coroutine 7 | from dataclasses import dataclass 8 | from typing import Any 9 | 10 | from homeassistant.components.number import ( 11 | NumberDeviceClass, 12 | NumberEntity, 13 | NumberEntityDescription, 14 | NumberMode, 15 | ) 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.exceptions import HomeAssistantError 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | from pyporscheconnectapi.vehicle import PorscheVehicle 21 | 22 | from . import ( 23 | PorscheBaseEntity, 24 | PorscheConnectDataUpdateCoordinator, 25 | ) 26 | from .const import DOMAIN 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | @dataclass(frozen=True, kw_only=True) 32 | class PorscheNumberEntityDescription(NumberEntityDescription): 33 | """Describes a Porsche number entity.""" 34 | 35 | value_fn: Callable[[PorscheVehicle], float | int | None] 36 | remote_service: Callable[[PorscheVehicle, float | int], Coroutine[Any, Any, Any]] 37 | is_available: Callable[[PorscheVehicle], bool] = lambda _: False 38 | 39 | 40 | NUMBER_TYPES: list[PorscheNumberEntityDescription] = [ 41 | PorscheNumberEntityDescription( 42 | key="target_soc", 43 | translation_key="target_soc", 44 | device_class=NumberDeviceClass.BATTERY, 45 | is_available=lambda v: v.has_electric_drivetrain and v.has_remote_services, 46 | native_max_value=100.0, 47 | native_min_value=25.0, 48 | native_unit_of_measurement="%", 49 | native_step=5.0, 50 | mode=NumberMode.SLIDER, 51 | value_fn=lambda v: v.charging_target, 52 | remote_service=lambda v, o: v.remote_services.set_target_soc( 53 | target_soc=int(o), 54 | ), 55 | ), 56 | ] 57 | 58 | 59 | async def async_setup_entry( 60 | hass: HomeAssistant, 61 | config_entry: ConfigEntry, 62 | async_add_entities: AddEntitiesCallback, 63 | ) -> None: 64 | """Set up the Porsche number from config entry.""" 65 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[DOMAIN][ 66 | config_entry.entry_id 67 | ] 68 | 69 | entities: list[PorscheNumber] = [] 70 | 71 | for vehicle in coordinator.vehicles: 72 | entities.extend( 73 | [ 74 | PorscheNumber(coordinator, vehicle, description) 75 | for description in NUMBER_TYPES 76 | if description.is_available(vehicle) 77 | ], 78 | ) 79 | async_add_entities(entities) 80 | 81 | 82 | class PorscheNumber(PorscheBaseEntity, NumberEntity): 83 | """Class describing Porsche Connect number entities.""" 84 | 85 | entity_description: PorscheNumberEntityDescription 86 | 87 | def __init__( 88 | self, 89 | coordinator: PorscheConnectDataUpdateCoordinator, 90 | vehicle: PorscheVehicle, 91 | description: PorscheNumberEntityDescription, 92 | ) -> None: 93 | """Initialize an Porsche Number.""" 94 | super().__init__(coordinator, vehicle) 95 | 96 | self.entity_description = description 97 | self._attr_unique_id = f"{vehicle.data['name']}-{description.key}" 98 | 99 | @property 100 | def native_value(self) -> float | None: 101 | """Return the entity value to represent the entity state.""" 102 | return self.entity_description.value_fn(self.vehicle) 103 | 104 | async def async_set_native_value(self, value: float) -> None: 105 | """Update to the vehicle.""" 106 | _LOGGER.debug( 107 | "Executing '%s' on vehicle '%s' to value '%s'", 108 | self.entity_description.key, 109 | self.vehicle.vin, 110 | value, 111 | ) 112 | try: 113 | await self.entity_description.remote_service(self.vehicle, value) 114 | except Exception as ex: 115 | raise HomeAssistantError(ex) from ex 116 | 117 | self.coordinator.async_update_listeners() 118 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/switch.py: -------------------------------------------------------------------------------- 1 | """Support for the Porsche Connect switch entities.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | from typing import Any 6 | 7 | from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.exceptions import HomeAssistantError 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from pyporscheconnectapi.exceptions import PorscheExceptionError 13 | from pyporscheconnectapi.vehicle import PorscheVehicle 14 | 15 | from . import ( 16 | PorscheBaseEntity, 17 | PorscheConnectDataUpdateCoordinator, 18 | ) 19 | from .const import DOMAIN 20 | 21 | 22 | @dataclass(frozen=True, kw_only=True) 23 | class PorscheSwitchEntityDescription(SwitchEntityDescription): 24 | """Class describing Porsche Connect switch entities.""" 25 | 26 | value_fn: Callable[[PorscheVehicle], bool] 27 | remote_service_on: Callable[[PorscheVehicle], Coroutine[Any, Any, Any]] 28 | remote_service_off: Callable[[PorscheVehicle], Coroutine[Any, Any, Any]] 29 | is_available: Callable[[PorscheVehicle], bool] = lambda _: False 30 | 31 | 32 | NUMBER_TYPES: list[PorscheSwitchEntityDescription] = [ 33 | PorscheSwitchEntityDescription( 34 | key="climatise", 35 | translation_key="climatise", 36 | is_available=lambda v: v.has_remote_climatisation and v.has_remote_services, 37 | value_fn=lambda v: v.remote_climatise_on, 38 | remote_service_on=lambda v: v.remote_services.climatise_on(), 39 | remote_service_off=lambda v: v.remote_services.climatise_off(), 40 | ), 41 | PorscheSwitchEntityDescription( 42 | key="direct_charging", 43 | translation_key="direct_charging", 44 | is_available=lambda v: v.has_direct_charge and v.has_remote_services, 45 | value_fn=lambda v: v.direct_charge_on, 46 | remote_service_on=lambda v: v.remote_services.direct_charge_on(), 47 | remote_service_off=lambda v: v.remote_services.direct_charge_off(), 48 | ), 49 | ] 50 | 51 | 52 | async def async_setup_entry( 53 | hass: HomeAssistant, 54 | config_entry: ConfigEntry, 55 | async_add_entities: AddEntitiesCallback, 56 | ) -> None: 57 | """Set up the Porsche switch from config entry.""" 58 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[DOMAIN][ 59 | config_entry.entry_id 60 | ] 61 | 62 | entities: list[PorscheSwitch] = [] 63 | 64 | for vehicle in coordinator.vehicles: 65 | entities.extend( 66 | [ 67 | PorscheSwitch(coordinator, vehicle, description) 68 | for description in NUMBER_TYPES 69 | if description.is_available(vehicle) 70 | ], 71 | ) 72 | async_add_entities(entities) 73 | 74 | 75 | class PorscheSwitch(PorscheBaseEntity, SwitchEntity): 76 | """Representation of Porsche switch.""" 77 | 78 | entity_description: PorscheSwitchEntityDescription 79 | 80 | def __init__( 81 | self, 82 | coordinator: PorscheConnectDataUpdateCoordinator, 83 | vehicle: PorscheVehicle, 84 | description: PorscheSwitchEntityDescription, 85 | ) -> None: 86 | """Initialize an Porsche Switch.""" 87 | super().__init__(coordinator, vehicle) 88 | self.entity_description = description 89 | self._attr_unique_id = f"{vehicle.vin}-{description.key}" 90 | 91 | @property 92 | def is_on(self) -> bool: 93 | """Return the entity value to represent the entity state.""" 94 | return self.entity_description.value_fn(self.vehicle) 95 | 96 | async def async_turn_on(self) -> None: 97 | """Turn the switch on.""" 98 | try: 99 | await self.entity_description.remote_service_on(self.vehicle) 100 | except PorscheExceptionError as ex: 101 | raise HomeAssistantError(ex) from ex 102 | 103 | self.coordinator.async_update_listeners() 104 | 105 | async def async_turn_off(self) -> None: 106 | """Turn the switch off.""" 107 | try: 108 | await self.entity_description.remote_service_off(self.vehicle) 109 | except PorscheExceptionError as ex: 110 | raise HomeAssistantError(ex) from ex 111 | 112 | self.coordinator.async_update_listeners() 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Porsche Connect 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![GitHub Activity][commits-shield]][commits] 5 | [![License][license-shield]](LICENSE) 6 | 7 | [![pre-commit][pre-commit-shield]][pre-commit] 8 | [![Black][black-shield]][black] 9 | 10 | [![hacs][hacs_badge]][hacs] 11 | [![Project Maintenance][maintenance-shield]][user_profile] 12 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 13 | 14 | [![Discord][discord-shield]][discord] 15 | [![Community Forum][forum-shield]][forum] 16 | 17 | This custom component for [home assistant](https://home-assistant.io/) will let you connect your Porsche Connect enabled 18 | car to Home Assistant. 19 | It does not work with Porsche Car Connect. 20 | Porsche Connect is available for the following Porsche models: 21 | 22 | - Boxster & Cayman (718) 23 | - 911 (from 992) 24 | - Taycan 25 | - Panamera (from 2021, G2 PA) 26 | - Macan (EV, from 2024) 27 | - Cayenne (from 2017, E3) 28 | 29 | You can also take a look here, select your model and see if your model has support for Porsche Connect: 30 | https://connect-store.porsche.com/ 31 | 32 | A Porsche Connect subscription also needs to be active. 33 | 34 | Feature requests and ideas are welcome, use the Discussions board for that, thanks! 35 | 36 | **This component will set up the following platforms.** 37 | 38 | | Platform | Description | 39 | | ---------------- | ------------------------------------- | 40 | | `binary_sensor` | Show something `True` or `False`. | 41 | | `button` | Trigger some action. | 42 | | `device_tracker` | Show your vehicles location. | 43 | | `image` | Show images of your vehicle. | 44 | | `lock` | Control the lock of your vehicle. | 45 | | `number` | Sends a number value to your vehicle. | 46 | | `sensor` | Show info from Porsche Connect API. | 47 | | `switch` | Switch something `On` or `Off`. | 48 | 49 | ## HACS Installation 50 | 51 | 1. Search for porscheconnect in HACS 52 | 2. Install 53 | 54 | ## Manual Installation 55 | 56 | 1. Using the tool of choice to open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 57 | 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 58 | 3. In the `custom_components` directory (folder) create a new folder called `porscheconnect`. 59 | 4. Download _all_ the files from the `custom_components/porscheconnect/` directory (folder) in this repository. 60 | 5. Place the files you downloaded in the new directory (folder) you created. 61 | 6. Restart Home Assistant 62 | 7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Porsche Connect" 63 | 64 | 65 | ## Configuration is done in the UI 66 | 67 | 68 | 69 | ## Contributions are welcome! 70 | 71 | If you want to contribute to this please read the [Contribution guidelines](https://github.com/CJNE/ha-porscheconnect/blob/main/CONTRIBUTING.md) 72 | 73 | --- 74 | 75 | [integration_blueprint]: https://github.com/custom-components/integration_blueprint 76 | [black]: https://github.com/psf/black 77 | [black-shield]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 78 | [buymecoffee]: https://www.buymeacoffee.com/cjne.coffee 79 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 80 | [commits-shield]: https://img.shields.io/github/commit-activity/y/CJNE/ha-porscheconnect.svg?style=for-the-badge 81 | [commits]: https://github.com/CJNE/ha-porscheconnect/commits/main 82 | [hacs]: https://hacs.xyz 83 | [hacs_badge]: https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge 84 | [discord]: https://discord.gg/Qa5fW2R 85 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 86 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 87 | [forum]: https://community.home-assistant.io/ 88 | [license-shield]: https://img.shields.io/github/license/CJNE/ha-porscheconnect.svg?style=for-the-badge 89 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40CJNE-blue.svg?style=for-the-badge 90 | [pre-commit]: https://github.com/pre-commit/pre-commit 91 | [pre-commit-shield]: https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=for-the-badge 92 | [releases-shield]: https://img.shields.io/github/release/CJNE/ha-porscheconnect.svg?style=for-the-badge 93 | [releases]: https://github.com/CJNE/ha-porscheconnect/releases 94 | [user_profile]: https://github.com/CJNE 95 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/services.py: -------------------------------------------------------------------------------- 1 | """Porsche Connect services.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Mapping 7 | 8 | import voluptuous as vol 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant, ServiceCall 11 | from homeassistant.exceptions import HomeAssistantError, ServiceValidationError 12 | from homeassistant.helpers import config_validation as cv 13 | from homeassistant.helpers import device_registry as dr 14 | from pyporscheconnectapi.exceptions import PorscheExceptionError 15 | from pyporscheconnectapi.vehicle import PorscheVehicle 16 | 17 | from . import ( 18 | PorscheConnectDataUpdateCoordinator, 19 | ) 20 | from .const import DOMAIN 21 | 22 | LOGGER = logging.getLogger(__name__) 23 | 24 | ATTR_VEHICLE = "vehicle" 25 | 26 | ATTR_TEMPERATURE = "temperature" 27 | ATTR_FRONT_LEFT = "front_left" 28 | ATTR_FRONT_RIGHT = "front_right" 29 | ATTR_REAR_LEFT = "rear_left" 30 | ATTR_REAR_RIGHT = "rear_right" 31 | 32 | SERVICE_VEHICLE_SCHEMA = vol.Schema( 33 | { 34 | vol.Required("vehicle"): cv.string, 35 | } 36 | ) 37 | 38 | SERVICE_CLIMATISATION_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( 39 | { 40 | vol.Optional(ATTR_TEMPERATURE): cv.positive_float, 41 | vol.Optional(ATTR_FRONT_LEFT): cv.boolean, 42 | vol.Optional(ATTR_FRONT_RIGHT): cv.boolean, 43 | vol.Optional(ATTR_REAR_LEFT): cv.boolean, 44 | vol.Optional(ATTR_REAR_RIGHT): cv.boolean, 45 | } 46 | ) 47 | 48 | SERVICE_CLIMATISATION_START = "climatisation_start" 49 | 50 | SERVICES = [ 51 | SERVICE_CLIMATISATION_START, 52 | ] 53 | 54 | 55 | def setup_services( 56 | hass: HomeAssistant, 57 | config_entry: ConfigEntry, 58 | ) -> None: 59 | """Register the Porsche Connect service actions.""" 60 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[DOMAIN][ 61 | config_entry.entry_id 62 | ] 63 | 64 | async def climatisation_start(service_call: ServiceCall) -> None: 65 | """Start climatisation.""" 66 | temperature: float = service_call.data.get(ATTR_TEMPERATURE) 67 | front_left: bool = service_call.data.get(ATTR_FRONT_LEFT) 68 | front_right: bool = service_call.data.get(ATTR_FRONT_RIGHT) 69 | rear_left: bool = service_call.data.get(ATTR_REAR_LEFT) 70 | rear_right: bool = service_call.data.get(ATTR_REAR_RIGHT) 71 | 72 | LOGGER.debug( 73 | "Starting climatisation: %s, %s, %s, %s, %s", 74 | temperature, 75 | front_left, 76 | front_right, 77 | rear_left, 78 | rear_right, 79 | ) 80 | vehicle = get_vehicle(service_call.data) 81 | try: 82 | await vehicle.remote_services.climatise_on( 83 | target_temperature=293.15 84 | if temperature is None 85 | else temperature + 273.15, 86 | front_left=front_left or False, 87 | front_right=front_right or False, 88 | rear_left=rear_left or False, 89 | rear_right=rear_right or False, 90 | ) 91 | coordinator.async_set_updated_data(vehicle.data) 92 | except PorscheExceptionError as ex: 93 | raise HomeAssistantError(ex) from ex 94 | 95 | hass.services.async_register( 96 | DOMAIN, 97 | SERVICE_CLIMATISATION_START, 98 | climatisation_start, 99 | schema=SERVICE_CLIMATISATION_START_SCHEMA, 100 | ) 101 | 102 | def get_vehicle(service_call_data: Mapping) -> PorscheVehicle: 103 | """Get vehicle from service_call data.""" 104 | device_registry = dr.async_get(hass) 105 | device_id = service_call_data[ATTR_VEHICLE] 106 | device_entry = device_registry.async_get(device_id) 107 | 108 | if device_entry is None: 109 | raise ServiceValidationError( 110 | translation_domain=DOMAIN, 111 | translation_key="invalid_device_id", 112 | translation_placeholders={"device_id": device_id}, 113 | ) 114 | 115 | for vehicle in coordinator.vehicles: 116 | if (DOMAIN, vehicle.vin) in device_entry.identifiers: 117 | return vehicle 118 | 119 | raise ServiceValidationError( 120 | translation_domain=DOMAIN, 121 | translation_key="no_config_entry_for_device", 122 | translation_placeholders={"device_id": device_entry.name or device_id}, 123 | ) 124 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "reauth_successful": "Re-authentication was successful" 6 | }, 7 | "error": { 8 | "cannot_connect": "Failed to connect", 9 | "invalid_auth": "Invalid authentication", 10 | "unknown": "Unexpected error" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "password": "Password", 16 | "email": "Email" 17 | } 18 | }, 19 | "captcha": { 20 | "title": "Captcha verification", 21 | "description": "{captcha_img}", 22 | "data": { 23 | "captcha_code": "Captcha code" 24 | } 25 | } 26 | } 27 | }, 28 | "entity": { 29 | "button": { 30 | "flash_indicators": {"name": "Flash indicators"}, 31 | "honk_and_flash_indicators": {"name": "Honk and flash indicators"}, 32 | "get_current_overview": {"name": "Get current vehicle information"} 33 | }, 34 | "binary_sensor": { 35 | "parking_brake": {"name": "Parking brake"}, 36 | "parking_light": {"name": "Parking light"}, 37 | "privacy_mode": {"name": "Privacy mode"}, 38 | "doors_and_lids": {"name": "Doors and lids"}, 39 | "tire_pressure_status": { 40 | "name": "Tire pressure status", 41 | "state": { 42 | "on": "Warning", 43 | "off": "Ok" 44 | } 45 | }, 46 | "remote_access": {"name": "Remote access"} 47 | }, 48 | "image": { 49 | "front_view": {"name": "View from the front"}, 50 | "side_view": {"name": "View from the side"}, 51 | "rear_view": {"name": "View from the rear"}, 52 | "rear_top_view": {"name": "View diagonally from above"}, 53 | "top_view": {"name": "View from above"} 54 | }, 55 | "sensor": { 56 | "charging_power": { 57 | "name": "Charging power" 58 | }, 59 | "charging_target": { 60 | "name": "Charging target" 61 | }, 62 | "charging_rate": { 63 | "name": "Charging rate" 64 | }, 65 | "charging_finished": { 66 | "name": "Charging ends" 67 | }, 68 | "remaining_range_electric": { 69 | "name": "Remaining range electric" 70 | }, 71 | "state_of_charge": { 72 | "name": "State of charge" 73 | }, 74 | "mileage": { 75 | "name": "Mileage" 76 | }, 77 | "remaining_range": { 78 | "name": "Remaining range" 79 | }, 80 | "fuel_level": { 81 | "name": "Fuel level" 82 | }, 83 | "charging_status": { 84 | "name": "Charging status", 85 | "state": { 86 | "charging": "Charging", 87 | "charging_completed": "Charging completed", 88 | "charging_error": "Charging error", 89 | "initialising": "Initialising charging", 90 | "instant_charging": "Instant charging", 91 | "not_charging": "Not charging", 92 | "not_plugged": "Not connected", 93 | "off": "Charging off" 94 | } 95 | } 96 | }, 97 | "number": { 98 | "target_soc": {"name": "Target state of charge"} 99 | }, 100 | "lock": { 101 | "lock": { 102 | "name": "Door lock" 103 | } 104 | }, 105 | "switch": { 106 | "climatise": {"name": "Remote climatisation"}, 107 | "direct_charging": {"name": "Direct charging"} 108 | } 109 | }, 110 | "services": { 111 | "climatisation_start": { 112 | "name": "Start remote climatisation", 113 | "description": "Starts remote climatisation of the passenger compartment with specified parameters", 114 | "fields": { 115 | "vehicle": { 116 | "name": "Vehicle", 117 | "description": "The vehicle to be climatised." 118 | }, 119 | "temperature": { 120 | "name": "Temperatur", 121 | "description": "Target temperature for climatisation (default 20 degrees C)" 122 | }, 123 | "front_left": { 124 | "name": "Seat heating front left", 125 | "description": "If front left seat heater should be activated (default off)" 126 | }, 127 | "front_right": { 128 | "name": "Seat heating front right", 129 | "description": "If front right seat heater should be activated (default off)" 130 | }, 131 | "rear_left": { 132 | "name": "Seat heating rear left", 133 | "description": "If rear left seat heater should be activated (default off)" 134 | }, 135 | "rear_right": { 136 | "name": "Seat heating rear right", 137 | "description": "If rear right seat heater should be activated (default off)" 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for the Porsche Connect binary sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | 9 | from homeassistant.components.binary_sensor import ( 10 | BinarySensorDeviceClass, 11 | BinarySensorEntity, 12 | BinarySensorEntityDescription, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import HomeAssistant, callback 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from pyporscheconnectapi.vehicle import PorscheVehicle 18 | 19 | from . import DOMAIN as PORSCHE_DOMAIN 20 | from . import ( 21 | PorscheBaseEntity, 22 | PorscheConnectDataUpdateCoordinator, 23 | ) 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | @dataclass(frozen=True) 29 | class PorscheBinarySensorEntityDescription(BinarySensorEntityDescription): 30 | """Class describing Porsche Connect binary sensor entities.""" 31 | 32 | measurement_node: str | None = None 33 | measurement_leaf: str | None = None 34 | value_fn: Callable[[PorscheVehicle], bool] | None = None 35 | attr_fn: Callable[[PorscheVehicle], dict[str, str]] | None = None 36 | is_available: Callable[[PorscheVehicle], bool] = lambda v: v.has_porsche_connect 37 | 38 | 39 | SENSOR_TYPES: list[PorscheBinarySensorEntityDescription] = [ 40 | PorscheBinarySensorEntityDescription( 41 | name="Remote access", 42 | key="remote_access", 43 | translation_key="remote_access", 44 | measurement_node="REMOTE_ACCESS_AUTHORIZATION", 45 | measurement_leaf="isEnabled", 46 | device_class=None, 47 | ), 48 | PorscheBinarySensorEntityDescription( 49 | name="Privacy mode", 50 | key="privacy_mode", 51 | translation_key="privacy_mode", 52 | measurement_node="GLOBAL_PRIVACY_MODE", 53 | measurement_leaf="isEnabled", 54 | device_class=None, 55 | ), 56 | PorscheBinarySensorEntityDescription( 57 | name="Parking brake", 58 | key="parking_brake", 59 | translation_key="parking_brake", 60 | measurement_node="PARKING_BRAKE", 61 | measurement_leaf="isOn", 62 | device_class=None, 63 | ), 64 | PorscheBinarySensorEntityDescription( 65 | name="Parking light", 66 | key="parking_light", 67 | translation_key="parking_light", 68 | measurement_node="PARKING_LIGHT", 69 | measurement_leaf="isOn", 70 | device_class=BinarySensorDeviceClass.LIGHT, 71 | ), 72 | PorscheBinarySensorEntityDescription( 73 | name="Doors and lids", 74 | key="doors_and_lids", 75 | translation_key="doors_and_lids", 76 | value_fn=lambda v: not v.vehicle_closed, 77 | attr_fn=lambda v: v.doors_and_lids, 78 | device_class=BinarySensorDeviceClass.OPENING, 79 | ), 80 | PorscheBinarySensorEntityDescription( 81 | name="Tire pressure status", 82 | key="tire_pressure_status", 83 | translation_key="tire_pressure_status", 84 | value_fn=lambda v: not v.tire_pressure_status, 85 | attr_fn=lambda v: v.tire_pressures, 86 | is_available=lambda v: v.has_tire_pressure_monitoring, 87 | device_class=BinarySensorDeviceClass.PROBLEM, 88 | ), 89 | ] 90 | 91 | 92 | async def async_setup_entry( 93 | hass: HomeAssistant, 94 | config_entry: ConfigEntry, 95 | async_add_entities: AddEntitiesCallback, 96 | ) -> None: 97 | """Set up the sensors from config entry.""" 98 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[PORSCHE_DOMAIN][ 99 | config_entry.entry_id 100 | ] 101 | 102 | entities = [ 103 | PorscheBinarySensor(coordinator, vehicle, description) 104 | for vehicle in coordinator.vehicles 105 | for description in SENSOR_TYPES 106 | if description.is_available(vehicle) 107 | ] 108 | 109 | async_add_entities(entities) 110 | 111 | 112 | class PorscheBinarySensor(BinarySensorEntity, PorscheBaseEntity): 113 | """Representation of a Porsche binary sensor.""" 114 | 115 | entity_description: PorscheBinarySensorEntityDescription 116 | 117 | def __init__( 118 | self, 119 | coordinator: PorscheConnectDataUpdateCoordinator, 120 | vehicle: PorscheVehicle, 121 | description: PorscheBinarySensorEntityDescription, 122 | ) -> None: 123 | """Initialize of the sensor.""" 124 | super().__init__(coordinator, vehicle) 125 | 126 | self.coordinator = coordinator 127 | self.entity_description = description 128 | self._attr_unique_id = f'{vehicle.data["name"]}-{description.key}' 129 | 130 | @callback 131 | def _handle_coordinator_update(self) -> None: 132 | """Handle updated data from the coordinator.""" 133 | if self.entity_description.value_fn: 134 | self._attr_is_on = self.entity_description.value_fn(self.vehicle) 135 | else: 136 | self._attr_is_on = self.coordinator.get_vechicle_data_leaf( 137 | self.vehicle, 138 | self.entity_description.measurement_node, 139 | self.entity_description.measurement_leaf, 140 | ) 141 | 142 | _LOGGER.debug( 143 | "Updating binary sensor '%s' of %s with state '%s'", 144 | self.entity_description.key, 145 | self.vehicle.data["name"], 146 | self._attr_is_on, 147 | # state, 148 | ) 149 | 150 | if self.entity_description.attr_fn: 151 | self._attr_extra_state_attributes = self.entity_description.attr_fn( 152 | self.vehicle, 153 | ) 154 | 155 | super()._handle_coordinator_update() 156 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/translations/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Enheten är redan konfigurerad", 5 | "reauth_successful": "Återautentiseringen lyckades" 6 | }, 7 | "error": { 8 | "cannot_connect": "Anslutningsfel", 9 | "invalid_auth": "Misslyckades att autentisera", 10 | "unknown": "Oväntat fel" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "password": "Lösenord", 16 | "email": "E-postadress" 17 | } 18 | }, 19 | "captcha": { 20 | "title": "Captchaverifiering", 21 | "description": "{captcha_img}", 22 | "data": { 23 | "captcha_code": "Captcha-kod" 24 | } 25 | } 26 | } 27 | }, 28 | "entity": { 29 | "binary_sensor": { 30 | "parking_brake": { 31 | "name": "Parkeringsbroms", 32 | "state": { 33 | "on": "På", 34 | "off": "Av" 35 | } 36 | }, 37 | "parking_light": { 38 | "name": "Parkeringsljus", 39 | "state": { 40 | "on": "Tänt", 41 | "off": "Släckt" 42 | } 43 | }, 44 | "privacy_mode": { 45 | "name": "Privat läge", 46 | "state": { 47 | "on": "På", 48 | "off": "Av" 49 | } 50 | }, 51 | "tire_pressure_status": { 52 | "name": "Däcktrycksövervakning", 53 | "state": { 54 | "on": "Varning", 55 | "off": "Ok" 56 | } 57 | }, 58 | "doors_and_lids": { 59 | "name": "Dörrar och luckor", 60 | "state": { 61 | "on": "Öppen", 62 | "off": "Stängd" 63 | } 64 | }, 65 | "remote_access": { 66 | "name": "Fjärrstyrning", 67 | "state": { 68 | "on": "På", 69 | "off": "Av" 70 | } 71 | } 72 | }, 73 | "button": { 74 | "flash_indicators": {"name": "Blinka"}, 75 | "honk_and_flash_indicators": {"name": "Tuta och blinka"}, 76 | "get_current_overview": {"name": "Uppdatera fordonsinformation"} 77 | }, 78 | "image": { 79 | "front_view": {"name": "Vy framifrån"}, 80 | "side_view": {"name": "Vy från sidan"}, 81 | "rear_view": {"name": "Vy bakifrån"}, 82 | "rear_top_view": {"name": "Vy snett ovanifrån"}, 83 | "top_view": {"name": "Vy ovanifrån"} 84 | }, 85 | "sensor": { 86 | "charging_power": { 87 | "name": "Laddeffekt" 88 | }, 89 | "charging_target": { 90 | "name": "Laddinställning" 91 | }, 92 | "charging_rate": { 93 | "name": "Laddhastighet" 94 | }, 95 | "charging_finished": { 96 | "name": "Laddning klar" 97 | }, 98 | "remaining_range_electric": { 99 | "name": "Räckvidd på batteri" 100 | }, 101 | "state_of_charge": { 102 | "name": "Laddnivå" 103 | }, 104 | "mileage": { 105 | "name": "Mätarställning" 106 | }, 107 | "remaining_range": { 108 | "name": "Återstående räckvidd" 109 | }, 110 | "fuel_level": { 111 | "name": "Bränslenivå" 112 | }, 113 | "charging_status": { 114 | "name": "Laddstatus", 115 | "state": { 116 | "charging": "Laddar", 117 | "charging_completed": "Laddning klar", 118 | "charging_error": "Laddfel", 119 | "initialising": "Initierar laddning", 120 | "instant_charging": "Direktladdar", 121 | "not_charging": "Laddar ej", 122 | "not_plugged": "Not connected", 123 | "off": "Laddning av" 124 | } 125 | } 126 | }, 127 | "number": { 128 | "target_soc": {"name": "Begärd laddnivå"} 129 | }, 130 | "lock": { 131 | "lock": { 132 | "name": "Dörrlås" 133 | } 134 | }, 135 | "switch": { 136 | "climatise": {"name": "Fjärrklimatisering"}, 137 | "direct_charging": {"name": "Direktladdning"} 138 | } 139 | }, 140 | "services": { 141 | "climatisation_start": { 142 | "name": "Starta klimatisering", 143 | "description": "Startar klimatisering av kupén med angivna parametrar.", 144 | "fields": { 145 | "vehicle": { 146 | "name": "Fordon", 147 | "description": "Det fordon som ska klimatiseras." 148 | }, 149 | "temperature": { 150 | "name": "Temperatur", 151 | "description": "Måltemperatur för klimatisering (standardvärde 20 grader)" 152 | }, 153 | "front_left": { 154 | "name": "Sätesvärme vänster fram", 155 | "description": "Om sätesvärmde vänster fram ska aktiveras (standardvärde av)" 156 | }, 157 | "front_right": { 158 | "name": "Sätesvärme höger fram", 159 | "description": "Om sätesvärmde höger fram ska aktiveras (standardvärde av)" 160 | }, 161 | "rear_left": { 162 | "name": "Sätesvärme vänster bak", 163 | "description": "Om sätesvärmde vänster bak ska aktiveras (standardvärde av)" 164 | }, 165 | "rear_right": { 166 | "name": "Sätesvärme höger bak", 167 | "description": "Om sätesvärmde höger bak ska aktiveras (standardvärde av)" 168 | } 169 | } 170 | } 171 | } 172 | } -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test Porsche Connect setup process.""" 2 | 3 | import pytest 4 | from custom_components.porscheconnect import async_reload_entry 5 | from custom_components.porscheconnect import async_setup_entry 6 | from custom_components.porscheconnect import async_unload_entry 7 | from custom_components.porscheconnect import PorscheConnectDataUpdateCoordinator 8 | from custom_components.porscheconnect.const import ( 9 | DOMAIN, 10 | ) 11 | from homeassistant.exceptions import ConfigEntryNotReady 12 | from homeassistant.helpers import aiohttp_client 13 | from homeassistant.helpers.update_coordinator import UpdateFailed 14 | from pyporscheconnectapi.connection import Connection 15 | from pyporscheconnectapi.account import PorscheConnectAccount 16 | from pytest_homeassistant_custom_component.common import MockConfigEntry 17 | 18 | from .const import MOCK_CONFIG 19 | 20 | 21 | # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture 22 | # for a given test. We can also leverage fixtures and mocks that are available in 23 | # Home Assistant using the pytest_homeassistant_custom_component plugin. 24 | # Assertions allow you to verify that the return value of whatever is on the left 25 | # side of the assertion matches with the right side. 26 | @pytest.mark.asyncio 27 | async def test_setup_unload_and_reload_entry(hass, mock_client): 28 | """Test entry setup and unload.""" 29 | # Create a mock entry so we don't have to go through config flow 30 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 31 | 32 | # Set up the entry and assert that the values set during setup are where we expect 33 | # them to be. Because we have patched the PorscheConnectDataUpdateCoordinator.async_get_data 34 | # call, no code from custom_components/porscheconnect/api.py actually runs. 35 | assert await async_setup_entry(hass, config_entry) 36 | assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] 37 | assert ( 38 | type(hass.data[DOMAIN][config_entry.entry_id]) 39 | is PorscheConnectDataUpdateCoordinator 40 | ) 41 | 42 | # Reload the entry and assert that the data from above is still there 43 | assert await async_reload_entry(hass, config_entry) is None 44 | assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] 45 | assert ( 46 | type(hass.data[DOMAIN][config_entry.entry_id]) 47 | is PorscheConnectDataUpdateCoordinator 48 | ) 49 | 50 | # Unload the entry and verify that the data has been removed 51 | assert await async_unload_entry(hass, config_entry) 52 | assert config_entry.entry_id not in hass.data[DOMAIN] 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_setup_entry_exception(hass, mock_client_error): 57 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 58 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 59 | 60 | # In this case we are testing the condition where async_setup_entry raises 61 | # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates 62 | # an error. 63 | with pytest.raises(ConfigEntryNotReady): 64 | assert await async_setup_entry(hass, config_entry) 65 | 66 | 67 | # Here we simiulate a successful config flow from the backend. 68 | # Note that we use the `bypass_get_data` fixture here because 69 | # we want the config flow validation to succeed during the test. 70 | # async def test_configured_instances(hass, bypass_connection_connect): 71 | # """Test a successful config flow.""" 72 | # # Initialize a config flow 73 | # await hass.config_entries.flow.async_init( 74 | # DOMAIN, context={"source": config_entries.SOURCE_USER} 75 | # ) 76 | @pytest.mark.asyncio 77 | async def test_setup_entry_initial_load(hass, mock_connection): 78 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 79 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 80 | 81 | # In this case we are testing the condition where async_setup_entry raises 82 | # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates 83 | # an error. 84 | assert await async_setup_entry(hass, config_entry) 85 | assert len(hass.data[DOMAIN][config_entry.entry_id].vehicles) == 1 86 | 87 | 88 | @pytest.mark.asyncio 89 | @pytest.mark.only 90 | async def test_setup_entry_initial_load_privacy(hass, mock_connection_privacy): 91 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 92 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 93 | 94 | # In this case we are testing the condition where async_setup_entry raises 95 | # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates 96 | # an error. 97 | assert await async_setup_entry(hass, config_entry) 98 | assert len(hass.data[DOMAIN][config_entry.entry_id].vehicles) == 1 99 | 100 | # This is a little hacky, we need to cover the case when privacy mode changes 101 | # Couldn't find a clean way to use different versions of the fixture 102 | update_coordinator = hass.data[DOMAIN][config_entry.entry_id] 103 | update_coordinator.config_entry = config_entry 104 | update_coordinator.vehicles[0]["privacyMode"] = False 105 | await update_coordinator._async_update_data() 106 | 107 | 108 | @pytest.mark.asyncio 109 | async def test_setup_entry_initial_load_no_perms(hass, mock_connection, mock_noaccess): 110 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 111 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 112 | 113 | assert await async_setup_entry(hass, config_entry) 114 | assert len(hass.data[DOMAIN][config_entry.entry_id].vehicles) == 0 115 | 116 | 117 | @pytest.mark.asyncio 118 | async def test_update_error(hass, mock_connection, mock_client_update_error): 119 | """Test ConfigEntryNotReady when API raises an exception during entry setup.""" 120 | config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") 121 | 122 | websession = aiohttp_client.async_get_clientsession(hass) 123 | connection = Connection( 124 | config_entry.data.get("email"), 125 | config_entry.data.get("password"), 126 | tokens=None, 127 | websession=websession, 128 | ) 129 | controller = PorscheConnectAccount(connection) 130 | 131 | coordinator = PorscheConnectDataUpdateCoordinator( 132 | hass, config_entry=config_entry, controller=controller 133 | ) 134 | with pytest.raises(UpdateFailed): 135 | assert await coordinator._async_update_data() 136 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/__init__.py: -------------------------------------------------------------------------------- 1 | """The Porsche Connect integration.""" 2 | 3 | import copy 4 | import logging 5 | import operator 6 | from datetime import timedelta 7 | from functools import reduce 8 | 9 | import async_timeout 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL 12 | from homeassistant.core import HomeAssistant, callback 13 | from homeassistant.helpers.device_registry import DeviceInfo 14 | from homeassistant.helpers.httpx_client import get_async_client 15 | from homeassistant.helpers.typing import ConfigType 16 | from homeassistant.helpers.update_coordinator import ( 17 | CoordinatorEntity, 18 | DataUpdateCoordinator, 19 | UpdateFailed, 20 | ) 21 | from pyporscheconnectapi.account import PorscheConnectAccount 22 | from pyporscheconnectapi.connection import Connection 23 | from pyporscheconnectapi.exceptions import PorscheExceptionError 24 | from pyporscheconnectapi.vehicle import PorscheVehicle 25 | 26 | from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) 30 | 31 | 32 | def get_from_dict(datadict, keystring): 33 | """Safely get value from dict.""" 34 | maplist = keystring.split(".") 35 | 36 | def safe_getitem(latest_value, key): 37 | if latest_value is None or key not in latest_value: 38 | return None 39 | return operator.getitem(latest_value, key) 40 | 41 | return reduce(safe_getitem, maplist, datadict) 42 | 43 | 44 | @callback 45 | def _async_save_token(hass, config_entry, access_token): 46 | hass.config_entries.async_update_entry( 47 | config_entry, 48 | data={ 49 | **config_entry.data, 50 | CONF_ACCESS_TOKEN: access_token, 51 | }, 52 | ) 53 | 54 | 55 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 56 | """Set up this integration using YAML is not supported.""" 57 | return True 58 | 59 | 60 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 61 | """Set up this integration using UI.""" 62 | if hass.data.get(DOMAIN) is None: 63 | hass.data.setdefault(DOMAIN, {}) 64 | 65 | async_client = get_async_client(hass) 66 | connection = Connection( 67 | entry.data.get("email"), 68 | entry.data.get("password"), 69 | async_client=async_client, 70 | token=entry.data.get(CONF_ACCESS_TOKEN, None), 71 | ) 72 | 73 | controller = PorscheConnectAccount( 74 | connection=connection, 75 | ) 76 | 77 | coordinator = PorscheConnectDataUpdateCoordinator( 78 | hass, 79 | config_entry=entry, 80 | controller=controller, 81 | ) 82 | await coordinator.async_config_entry_first_refresh() 83 | hass.data[DOMAIN][entry.entry_id] = coordinator 84 | 85 | await hass.config_entries.async_forward_entry_setups( 86 | entry, 87 | list(PLATFORMS), 88 | ) 89 | 90 | _async_save_token(hass, entry, controller.token) 91 | 92 | from .services import setup_services 93 | 94 | setup_services(hass, entry) 95 | 96 | return True 97 | 98 | 99 | class PorscheConnectDataUpdateCoordinator(DataUpdateCoordinator): 100 | """Class to manage fetching Porsche data.""" 101 | 102 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, controller): 103 | """Initialise the controller.""" 104 | self.controller = controller 105 | self.vehicles = [] 106 | self.hass = hass 107 | self.config_entry = config_entry 108 | 109 | scan_interval = timedelta( 110 | seconds=config_entry.options.get( 111 | CONF_SCAN_INTERVAL, 112 | config_entry.data.get( 113 | CONF_SCAN_INTERVAL, 114 | SCAN_INTERVAL.total_seconds(), 115 | ), 116 | ), 117 | ) 118 | 119 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=scan_interval) 120 | 121 | def get_vechicle_data_leaf(self, vehicle, node, leaf): 122 | """Get data value leaf from dict.""" 123 | return get_from_dict(get_from_dict(vehicle.data, node), leaf) 124 | 125 | async def _async_update_data(self): 126 | """Fetch data from API endpoint.""" 127 | try: 128 | if len(self.vehicles) == 0: 129 | self.vehicles = await self.controller.get_vehicles() 130 | 131 | for vehicle in self.vehicles: 132 | await vehicle.get_stored_overview() 133 | await vehicle.get_picture_locations() 134 | 135 | else: 136 | async with async_timeout.timeout(30): 137 | for vehicle in self.vehicles: 138 | await vehicle.get_stored_overview() 139 | 140 | except PorscheExceptionError as exc: 141 | msg = "Error communicating with API: %s" 142 | raise UpdateFailed(msg, exc) from exc 143 | else: 144 | accesstoken = copy.deepcopy(self.controller.token) 145 | 146 | _async_save_token( 147 | hass=self.hass, 148 | config_entry=self.config_entry, 149 | access_token=accesstoken, 150 | ) 151 | return {} 152 | 153 | 154 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 155 | """Unload a config entry.""" 156 | unload_ok = await hass.config_entries.async_unload_platforms( 157 | entry, 158 | list(PLATFORMS), 159 | ) 160 | 161 | if unload_ok: 162 | hass.data[DOMAIN].pop(entry.entry_id) 163 | 164 | return unload_ok 165 | 166 | 167 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 168 | """Reload config entry.""" 169 | _LOGGER.info("Reloading config entry: %s", entry) 170 | await async_unload_entry(hass, entry) 171 | await async_setup_entry(hass, entry) 172 | 173 | 174 | class PorscheBaseEntity(CoordinatorEntity): 175 | """Common base for entities.""" 176 | 177 | coordinator: PorscheConnectDataUpdateCoordinator 178 | _attr_has_entity_name = True 179 | 180 | def __init__( 181 | self, 182 | coordinator: PorscheConnectDataUpdateCoordinator, 183 | vehicle: PorscheVehicle, 184 | ) -> None: 185 | """Initialise the entity.""" 186 | super().__init__(coordinator) 187 | 188 | self.vehicle = vehicle 189 | 190 | self._attr_device_info = DeviceInfo( 191 | identifiers={(DOMAIN, self.vehicle.vin)}, 192 | name=vehicle.data["name"], 193 | model=vehicle.data["modelName"], 194 | manufacturer="Porsche", 195 | serial_number=vehicle.vin, 196 | ) 197 | 198 | @property 199 | def vin(self) -> str: 200 | """Get the VIN (vehicle identification number) of the vehicle.""" 201 | return self.vehicle.vin 202 | 203 | async def async_added_to_hass(self) -> None: 204 | """When entity is added to hass.""" 205 | await super().async_added_to_hass() 206 | self._handle_coordinator_update() 207 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Porsche Connect integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import base64 6 | import logging 7 | 8 | import voluptuous as vol 9 | from homeassistant import exceptions 10 | from homeassistant.config_entries import ( 11 | CONN_CLASS_CLOUD_POLL, 12 | ConfigFlow, 13 | ConfigFlowResult, 14 | ) 15 | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD 16 | from homeassistant.core import callback 17 | from pyporscheconnectapi.connection import Connection 18 | from pyporscheconnectapi.exceptions import ( 19 | PorscheCaptchaRequiredError, 20 | PorscheExceptionError, 21 | PorscheWrongCredentialsError, 22 | ) 23 | 24 | from .const import DOMAIN 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | STEP_USER_DATA_SCHEMA = vol.Schema({CONF_EMAIL: str, CONF_PASSWORD: str}) 29 | CHANGE_PASSWORD_SCHEMA = vol.Schema({CONF_PASSWORD: str}) 30 | 31 | 32 | async def validate_input(data): 33 | """Validate the user input allows us to connect. 34 | 35 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 36 | """ 37 | token = {} 38 | try: 39 | conn = Connection( 40 | email=data[CONF_EMAIL], 41 | password=data[CONF_PASSWORD], 42 | captcha_code=data.get("captcha_code"), 43 | state=data.get("state"), 44 | token=token, 45 | ) 46 | except PorscheExceptionError as exc: 47 | _LOGGER.debug("Exception %s", exc) 48 | 49 | _LOGGER.debug("Attempting login") 50 | try: 51 | token = await conn.get_token() 52 | except PorscheCaptchaRequiredError as exc: 53 | _LOGGER.info("Captcha required to log in: %s", exc.captcha) 54 | return { 55 | "email": data[CONF_EMAIL], 56 | "password": data[CONF_PASSWORD], 57 | "captcha": exc.captcha, 58 | "state": exc.state, 59 | } 60 | except PorscheWrongCredentialsError as exc: 61 | _LOGGER.info("Wrong credentials.") 62 | raise InvalidAuth from exc 63 | except PorscheExceptionError as exc: 64 | _LOGGER.info("Authentication flow error: %s", exc) 65 | raise InvalidAuth from exc 66 | except Exception as exc: 67 | _LOGGER.info("Login failed: %s", exc) 68 | raise InvalidAuth from exc 69 | 70 | return {"title": data[CONF_EMAIL], CONF_ACCESS_TOKEN: token} 71 | 72 | 73 | class ConfigFlow(ConfigFlow, domain=DOMAIN): 74 | """Handle a config flow for Porsche Connect.""" 75 | 76 | VERSION = 1 77 | CONNECTION_CLASS = CONN_CLASS_CLOUD_POLL 78 | 79 | email = None 80 | password = None 81 | captcha = None 82 | state = None 83 | 84 | async def async_step_user(self, user_input=None): 85 | """Handle the initial step.""" 86 | if user_input is None: 87 | return self.async_show_form( 88 | step_id="user", 89 | data_schema=STEP_USER_DATA_SCHEMA, 90 | ) 91 | 92 | errors = {} 93 | _LOGGER.debug("Validating input.") 94 | 95 | unique_id = f"{user_input[CONF_EMAIL]}" 96 | await self.async_set_unique_id(unique_id) 97 | 98 | try: 99 | info = await validate_input(user_input) 100 | if info.get("captcha") and info.get("state"): 101 | self.email = info.get("email") 102 | self.password = info.get("password") 103 | self.captcha = info.get("captcha") 104 | self.state = info.get("state") 105 | return self._async_form_captcha() 106 | entry_data = { 107 | **user_input, 108 | CONF_ACCESS_TOKEN: info.get(CONF_ACCESS_TOKEN), 109 | } 110 | return self.async_create_entry( 111 | title=info["title"], 112 | data=entry_data, 113 | ) 114 | 115 | except InvalidAuth: 116 | errors["base"] = "invalid_auth" 117 | 118 | return self.async_show_form( 119 | step_id="user", 120 | data_schema=STEP_USER_DATA_SCHEMA, 121 | errors=errors, 122 | ) 123 | 124 | async def async_step_reauth(self, user_input=None) -> ConfigFlowResult: 125 | """Handle configuration by re-auth.""" 126 | self._reauth_entry = self.hass.config_entries.async_get_entry( 127 | self.context["entry_id"], 128 | ) 129 | return await self.async_step_user() 130 | 131 | async def async_step_reconfigure(self, user_input=None) -> ConfigFlowResult: 132 | """Handle a reconfiguration flow initialized by the user.""" 133 | self._existing_entry_data = dict(self._get_reconfigure_entry().data) 134 | return await self.async_step_change_password() 135 | 136 | async def async_step_change_password(self, user_input=None) -> ConfigFlowResult: 137 | """Show the change password step.""" 138 | if user_input is not None: 139 | return await self.async_step_user(self._existing_entry_data | user_input) 140 | 141 | return self.async_show_form( 142 | step_id="change_password", 143 | data_schema=CHANGE_PASSWORD_SCHEMA, 144 | description_placeholders={ 145 | CONF_EMAIL: self._existing_entry_data[CONF_EMAIL], 146 | }, 147 | ) 148 | 149 | async def async_step_captcha( 150 | self, 151 | user_input: dict[str, str] | None = None, 152 | ) -> ConfigFlowResult: 153 | """Captcha verification step.""" 154 | if user_input is not None: 155 | user_input = { 156 | "email": self.email, 157 | "password": self.password, 158 | "captcha_code": user_input["captcha_code"], 159 | "state": self.state, 160 | } 161 | try: 162 | info = await validate_input(user_input) 163 | entry_data = { 164 | **user_input, 165 | CONF_ACCESS_TOKEN: info.get(CONF_ACCESS_TOKEN), 166 | } 167 | return self.async_create_entry( 168 | title=info["title"], 169 | data=entry_data, 170 | ) 171 | 172 | except Exception as exc: 173 | _LOGGER.info("Login failed: %s", exc) 174 | raise InvalidAuth from exc 175 | 176 | return self._async_form_captcha() 177 | 178 | @callback 179 | def _async_form_captcha( 180 | self, 181 | ) -> ConfigFlowResult: 182 | """Captcha verification form.""" 183 | # We edit the SVG for better visibility 184 | 185 | (header, payload) = self.captcha.split(",") 186 | svg = base64.b64decode(payload) 187 | svg = svg.replace( 188 | b'width="150" height="50"', 189 | b'width="300" height="100" style="background-color:white"', 190 | ) 191 | payload = base64.b64encode(svg) 192 | self.captcha = header + "," + payload.decode("ascii") 193 | 194 | return self.async_show_form( 195 | step_id="captcha", 196 | data_schema=vol.Schema( 197 | { 198 | vol.Required("captcha_code", default=vol.UNDEFINED): str, 199 | }, 200 | ), 201 | description_placeholders={ 202 | "captcha_img": '', 203 | }, 204 | ) 205 | 206 | 207 | class InvalidAuth(exceptions.HomeAssistantError): 208 | """Error to indicate there is invalid auth.""" 209 | 210 | 211 | class CaptchaRequired(exceptions.HomeAssistantError): 212 | """Error to indicate captcha verification is required.""" 213 | -------------------------------------------------------------------------------- /custom_components/porscheconnect/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for the Porsche Connect sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | 9 | from homeassistant.components.sensor import ( 10 | SensorDeviceClass, 11 | SensorEntity, 12 | SensorEntityDescription, 13 | SensorStateClass, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.const import ( 17 | PERCENTAGE, 18 | UnitOfLength, 19 | UnitOfPower, 20 | UnitOfSpeed, 21 | ) 22 | from homeassistant.core import HomeAssistant, callback 23 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 24 | from pyporscheconnectapi.vehicle import PorscheVehicle 25 | 26 | from . import DOMAIN as PORSCHE_DOMAIN 27 | from . import ( 28 | PorscheBaseEntity, 29 | PorscheConnectDataUpdateCoordinator, 30 | ) 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | 35 | @dataclass(frozen=True) 36 | class PorscheSensorEntityDescription(SensorEntityDescription): 37 | """Class describing Porsche Connect sensor entities.""" 38 | 39 | measurement_node: str | None = None 40 | measurement_leaf: str | None = None 41 | is_available: Callable[[PorscheVehicle], bool] = lambda v: v.has_porsche_connect 42 | 43 | 44 | SENSOR_TYPES: list[PorscheSensorEntityDescription] = [ 45 | PorscheSensorEntityDescription( 46 | key="charging_target", 47 | translation_key="charging_target", 48 | measurement_node="CHARGING_SUMMARY", 49 | measurement_leaf="minSoC", 50 | device_class=None, 51 | native_unit_of_measurement=PERCENTAGE, 52 | state_class=None, 53 | suggested_display_precision=0, 54 | icon="mdi:battery-high", 55 | is_available=lambda v: v.has_electric_drivetrain, 56 | ), 57 | PorscheSensorEntityDescription( 58 | key="charging_status", 59 | translation_key="charging_status", 60 | measurement_node="CHARGING_SUMMARY", 61 | measurement_leaf="status", 62 | icon="mdi:battery-charging", 63 | device_class=SensorDeviceClass.ENUM, 64 | is_available=lambda v: v.has_electric_drivetrain, 65 | ), 66 | PorscheSensorEntityDescription( 67 | key="charging_rate", 68 | translation_key="charging_rate", 69 | measurement_node="CHARGING_RATE", 70 | measurement_leaf="chargingRate-kph", 71 | icon="mdi:speedometer", 72 | device_class=SensorDeviceClass.SPEED, 73 | native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, 74 | is_available=lambda v: v.has_electric_drivetrain, 75 | ), 76 | PorscheSensorEntityDescription( 77 | key="charging_finished", 78 | translation_key="charging_finished", 79 | measurement_node="CHARGING_SUMMARY", 80 | measurement_leaf="targetDateTime", 81 | icon="mdi:clock-end", 82 | device_class=SensorDeviceClass.TIMESTAMP, 83 | is_available=lambda v: v.has_electric_drivetrain, 84 | ), 85 | PorscheSensorEntityDescription( 86 | key="charging_power", 87 | translation_key="charging_power", 88 | measurement_node="CHARGING_RATE", 89 | measurement_leaf="chargingPower", 90 | icon="mdi:lightning-bolt-circle", 91 | device_class=SensorDeviceClass.POWER, 92 | native_unit_of_measurement=UnitOfPower.KILO_WATT, 93 | is_available=lambda v: v.has_electric_drivetrain, 94 | ), 95 | PorscheSensorEntityDescription( 96 | key="remaining_range_electric", 97 | translation_key="remaining_range_electric", 98 | measurement_node="E_RANGE", 99 | measurement_leaf="kilometers", 100 | icon="mdi:gauge", 101 | device_class=SensorDeviceClass.DISTANCE, 102 | native_unit_of_measurement=UnitOfLength.KILOMETERS, 103 | state_class=SensorStateClass.MEASUREMENT, 104 | suggested_display_precision=0, 105 | is_available=lambda v: v.has_electric_drivetrain, 106 | ), 107 | PorscheSensorEntityDescription( 108 | key="state_of_charge", 109 | translation_key="state_of_charge", 110 | measurement_node="BATTERY_LEVEL", 111 | measurement_leaf="percent", 112 | icon="mdi:battery-medium", 113 | device_class=SensorDeviceClass.BATTERY, 114 | native_unit_of_measurement=PERCENTAGE, 115 | state_class=SensorStateClass.MEASUREMENT, 116 | suggested_display_precision=0, 117 | is_available=lambda v: v.has_electric_drivetrain, 118 | ), 119 | PorscheSensorEntityDescription( 120 | key="mileage", 121 | translation_key="mileage", 122 | measurement_node="MILEAGE", 123 | measurement_leaf="kilometers", 124 | icon="mdi:counter", 125 | device_class=SensorDeviceClass.DISTANCE, 126 | native_unit_of_measurement=UnitOfLength.KILOMETERS, 127 | state_class=SensorStateClass.TOTAL_INCREASING, 128 | suggested_display_precision=0, 129 | ), 130 | PorscheSensorEntityDescription( 131 | key="remaining_range", 132 | translation_key="remaining_range", 133 | measurement_node="RANGE", 134 | measurement_leaf="kilometers", 135 | icon="mdi:gas-station", 136 | device_class=SensorDeviceClass.DISTANCE, 137 | native_unit_of_measurement=UnitOfLength.KILOMETERS, 138 | state_class=SensorStateClass.MEASUREMENT, 139 | suggested_display_precision=0, 140 | is_available=lambda v: v.has_ice_drivetrain, 141 | ), 142 | PorscheSensorEntityDescription( 143 | key="fuel_level", 144 | translation_key="fuel_level", 145 | measurement_node="FUEL_LEVEL", 146 | measurement_leaf="percent", 147 | icon="mdi:gas-station", 148 | native_unit_of_measurement=PERCENTAGE, 149 | state_class=SensorStateClass.MEASUREMENT, 150 | suggested_display_precision=0, 151 | is_available=lambda v: v.has_ice_drivetrain, 152 | ), 153 | ] 154 | 155 | 156 | async def async_setup_entry( 157 | hass: HomeAssistant, 158 | config_entry: ConfigEntry, 159 | async_add_entities: AddEntitiesCallback, 160 | ) -> None: 161 | """Set up the sensors from config entry.""" 162 | coordinator: PorscheConnectDataUpdateCoordinator = hass.data[PORSCHE_DOMAIN][ 163 | config_entry.entry_id 164 | ] 165 | 166 | entities = [ 167 | PorscheSensor(coordinator, vehicle, description) 168 | for vehicle in coordinator.vehicles 169 | for description in SENSOR_TYPES 170 | if description.is_available(vehicle) 171 | ] 172 | 173 | async_add_entities(entities) 174 | 175 | 176 | class PorscheSensor(PorscheBaseEntity, SensorEntity): 177 | """Representation of a Porsche sensor.""" 178 | 179 | entity_description: PorscheSensorEntityDescription 180 | 181 | def __init__( 182 | self, 183 | coordinator: PorscheConnectDataUpdateCoordinator, 184 | vehicle: PorscheVehicle, 185 | description: PorscheSensorEntityDescription, 186 | ) -> None: 187 | """Initialize of the sensor.""" 188 | super().__init__(coordinator, vehicle) 189 | 190 | self.entity_description = description 191 | self._attr_unique_id = f"{vehicle.data['name']}-{description.key}" 192 | 193 | @callback 194 | def _handle_coordinator_update(self) -> None: 195 | """Handle updated data from the coordinator.""" 196 | state = self.coordinator.get_vechicle_data_leaf( 197 | self.vehicle, 198 | self.entity_description.measurement_node, 199 | self.entity_description.measurement_leaf, 200 | ) 201 | 202 | if type(state) is str: 203 | state = state.lower() 204 | 205 | _LOGGER.debug( 206 | "Updating sensor '%s' of %s with state '%s'", 207 | self.entity_description.key, 208 | self.vehicle.data["name"], 209 | state, 210 | ) 211 | 212 | self._attr_native_value = state 213 | super()._handle_coordinator_update() 214 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for Porsche Connect integration.""" 2 | 3 | import asyncio 4 | from typing import Any 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | from pyporscheconnectapi.exceptions import PorscheException 9 | from pyporscheconnectapi.exceptions import PorscheWrongCredentials 10 | 11 | from . import load_fixture_json 12 | 13 | # from unittest.mock import Mock 14 | 15 | pytest_plugins = "pytest_homeassistant_custom_component" 16 | 17 | 18 | def async_return(result): 19 | f = asyncio.Future() 20 | f.set_result(result) 21 | return f 22 | 23 | 24 | # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent 25 | # notifications. These calls would fail without this fixture since the persistent_notification 26 | # integration is never loaded during a test. 27 | @pytest.fixture(name="skip_notifications", autouse=True) 28 | def skip_notifications_fixture(): 29 | """Skip notification calls.""" 30 | with patch("homeassistant.components.persistent_notification.async_create"), patch( 31 | "homeassistant.components.persistent_notification.async_dismiss" 32 | ): 33 | yield 34 | 35 | 36 | @pytest.fixture(name="auto_enable_custom_integrations", autouse=True) 37 | def auto_enable_custom_integrations( 38 | hass: Any, enable_custom_integrations: Any # noqa: F811 39 | ) -> None: 40 | """Enable custom integrations defined in the test dir.""" 41 | 42 | 43 | @pytest.fixture 44 | def mock_connection(): 45 | """Prevent setup.""" 46 | 47 | fixture_name = "taycan" 48 | fixture_data = load_fixture_json(fixture_name) 49 | print(f"Using mock connedion fixture {fixture_name}") 50 | 51 | async def mock_get(self, url, params=None): 52 | print(f"Mock connection GET {url}") 53 | print(params) 54 | ret = fixture_data["GET"].get(url, {}) 55 | print(ret) 56 | return ret 57 | 58 | async def mock_post(self, url, data=None, json=None): 59 | print(f"Mock connection POST {url}") 60 | print(data) 61 | print(json) 62 | ret = fixture_data["POST"].get(url, {}) 63 | print(ret) 64 | return ret 65 | 66 | async def mock_tokens(self, application, wasExpired=False): 67 | print(f"Request mock token for {application}") 68 | return {} 69 | 70 | async def mock_getAllTokens(self): 71 | return {} 72 | 73 | with patch("pyporscheconnectapi.client.Connection.get", mock_get), patch( 74 | "pyporscheconnectapi.client.Connection.post", mock_post 75 | ), patch( 76 | "pyporscheconnectapi.client.Connection.getAllTokens", mock_getAllTokens 77 | ), patch( 78 | "pyporscheconnectapi.client.Connection._requestToken", mock_tokens 79 | ): 80 | yield 81 | 82 | 83 | @pytest.fixture 84 | def mock_connection_privacy(): 85 | """Prevent setup.""" 86 | 87 | fixture_name = "taycan_privacy" 88 | fixture_data = load_fixture_json(fixture_name) 89 | 90 | print(f"Using mock connedion fixture {fixture_name}") 91 | 92 | async def mock_get(self, url, params=None): 93 | print(f"Mock connection GET {url}") 94 | print(params) 95 | ret = fixture_data["GET"].get(url, {}) 96 | print(ret) 97 | return ret 98 | 99 | async def mock_post(self, url, data=None, json=None): 100 | print(f"Mock connection POST {url}") 101 | print(data) 102 | print(json) 103 | ret = fixture_data["POST"].get(url, {}) 104 | print(ret) 105 | return ret 106 | 107 | async def mock_tokens(self, application, wasExpired=False): 108 | print(f"Request mock token for {application}") 109 | return {} 110 | 111 | async def mock_getAllTokens(self): 112 | return {} 113 | 114 | with patch("pyporscheconnectapi.client.Connection.get", mock_get), patch( 115 | "pyporscheconnectapi.client.Connection.post", mock_post 116 | ), patch( 117 | "pyporscheconnectapi.client.Connection.getAllTokens", mock_getAllTokens 118 | ), patch( 119 | "pyporscheconnectapi.client.Connection._requestToken", mock_tokens 120 | ): 121 | yield 122 | 123 | 124 | @pytest.fixture 125 | def mock_noaccess(mock_connection): 126 | """Return a mocked client object.""" 127 | 128 | async def mock_access(self, vin): 129 | return {"allowed": False, "reason": "Test reason"} 130 | 131 | with patch("custom_components.porscheconnect.Client.isAllowed", mock_access): 132 | yield 133 | 134 | 135 | @pytest.fixture 136 | def mock_lock_lock(mock_connection): 137 | """Return a mocked client object.""" 138 | with patch("custom_components.porscheconnect.Client.lock") as mock_lock: 139 | yield mock_lock 140 | 141 | 142 | @pytest.fixture 143 | def mock_lock_unlock(mock_connection): 144 | """Return a mocked client object.""" 145 | with patch("custom_components.porscheconnect.Client.unlock") as mock_unlock: 146 | yield mock_unlock 147 | 148 | 149 | @pytest.fixture 150 | def mock_set_charging_level(mock_connection): 151 | """Return a mocked client object.""" 152 | with patch( 153 | "custom_components.porscheconnect.Client.updateChargingProfile" 154 | ) as set_level: 155 | yield set_level 156 | 157 | 158 | @pytest.fixture 159 | def mock_honk_and_flash(mock_connection): 160 | """Return a mocked client object.""" 161 | with patch("custom_components.porscheconnect.Client.honkAndFlash") as honkflash: 162 | yield honkflash 163 | 164 | 165 | @pytest.fixture 166 | def mock_flash(mock_connection): 167 | """Return a mocked client object.""" 168 | with patch("custom_components.porscheconnect.Client.flash") as flash: 169 | yield flash 170 | 171 | 172 | @pytest.fixture 173 | def mock_set_climate_on(mock_connection): 174 | """Return a mocked client object.""" 175 | with patch("custom_components.porscheconnect.Client.climateOn") as climate_on: 176 | yield climate_on 177 | 178 | 179 | @pytest.fixture 180 | def mock_set_climate_off(mock_connection): 181 | """Return a mocked client object.""" 182 | with patch("custom_components.porscheconnect.Client.climateOff") as climate_off: 183 | yield climate_off 184 | 185 | 186 | @pytest.fixture 187 | def mock_set_charge_on(mock_connection): 188 | """Return a mocked client object.""" 189 | with patch("custom_components.porscheconnect.Client.directChargeOn") as charge_on: 190 | yield charge_on 191 | 192 | 193 | @pytest.fixture 194 | def mock_set_charge_off(mock_connection): 195 | """Return a mocked client object.""" 196 | with patch("custom_components.porscheconnect.Client.directChargeOff") as charge_off: 197 | yield charge_off 198 | 199 | 200 | @pytest.fixture(name="mock_client") 201 | def mock_client_fixture(): 202 | """Prevent setup.""" 203 | with patch("custom_components.porscheconnect.Client") as mock: 204 | instance = mock.return_value 205 | instance.getVehicles.return_value = async_return([]) 206 | instance.getAllTokens.return_value = async_return([]) 207 | instance.isTokenRefreshed.return_value = False 208 | yield 209 | 210 | 211 | @pytest.fixture(name="mock_client_error") 212 | def mock_client_error_fixture(): 213 | """Prevent setup.""" 214 | with patch("custom_components.porscheconnect.Client") as mock: 215 | instance = mock.return_value 216 | instance.getVehicles.return_value = async_return([]) 217 | instance.getVehicles.side_effect = PorscheException("Test") 218 | instance.getAllTokens.return_value = async_return([]) 219 | yield 220 | 221 | 222 | @pytest.fixture 223 | def mock_client_update_error(mock_connection): 224 | """Prevent setup.""" 225 | with patch( 226 | "custom_components.porscheconnect.Client.getPosition", 227 | side_effect=PorscheException, 228 | ): 229 | yield 230 | 231 | 232 | # This fixture, when used, will result in calls to async_get_data to return None. To have the call 233 | # return a value, we would add the `return_value=` parameter to the patch call. 234 | @pytest.fixture(name="bypass_connection_connect") 235 | def bypass_connection_connect_fixture(): 236 | print("Using bypass connection connect") 237 | """Skip calls to get data from API.""" 238 | with patch("pyporscheconnectapi.connection.Connection._login"), patch( 239 | "pyporscheconnectapi.connection.Connection.getAllTokens" 240 | ): 241 | yield 242 | 243 | 244 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 245 | # for exception handling. 246 | @pytest.fixture(name="error_connection_connect") 247 | def error_connection_connect_fixture(): 248 | """Simulate error when retrieving data from API.""" 249 | with patch( 250 | "pyporscheconnectapi.connection.Connection._login", 251 | side_effect=Exception, 252 | ), patch("pyporscheconnectapi.connection.Connection.getAllTokens"): 253 | yield 254 | 255 | 256 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 257 | # for exception handling. 258 | @pytest.fixture(name="error_connection_login") 259 | def error_connection_login_fixture(): 260 | """Simulate error when retrieving data from API.""" 261 | with patch( 262 | "pyporscheconnectapi.connection.Connection._login", 263 | side_effect=PorscheWrongCredentials, 264 | ), patch("pyporscheconnectapi.connection.Connection.getAllTokens"): 265 | yield 266 | 267 | 268 | # This fixture, when used, will result in calls to async_get_data to return None. To have the call 269 | # return a value, we would add the `return_value=` parameter to the patch call. 270 | @pytest.fixture(name="bypass_get_data") 271 | def bypass_get_data_fixture(): 272 | """Skip calls to get data from API.""" 273 | with patch( 274 | "custom_components.porscheconnect.PorscheConnectApiClient.async_get_data" 275 | ): 276 | yield 277 | 278 | 279 | # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful 280 | # for exception handling. 281 | @pytest.fixture(name="error_on_get_data") 282 | def error_get_data_fixture(): 283 | """Simulate error when retrieving data from API.""" 284 | with patch( 285 | "custom_components.porscheconnect.PorscheConnectApiClient.async_get_data", 286 | side_effect=Exception, 287 | ): 288 | yield 289 | -------------------------------------------------------------------------------- /tests/fixtures/taycan.json: -------------------------------------------------------------------------------- 1 | { 2 | "POST": { 3 | "https://api.porsche.com/service-vehicle/de/de_DE/vehicle-data/WPTAYCAN/current/request": { 4 | "requestId": "abc" 5 | } 6 | }, 7 | "GET": { 8 | "https://api.porsche.com/core/api/v3/de/de_DE/services?WPTAYCAN": { 9 | "settingsUrl": "", 10 | "services": [] 11 | }, 12 | "https://api.porsche.com/profiles/mydata?country=de": { 13 | "ciamId": "xxghxxxcbdwxyxuv", 14 | "porscheId": "testuser@testemail.com", 15 | "acceptedDataPrivacyDate": "2022-01-05T07:37:58Z", 16 | "acceptedDataPrivacyVersion": "PorscheConnectDPG/SE/sv_SE/v3.8", 17 | "acceptedDealerConsentDate": "2021-01-11T11:47:31Z", 18 | "acceptedDealerConsentVersion": "PorscheConnectDPG_DC/SE/sv_SE/v2.0", 19 | "acceptedTermsAndConditionsDate": "2021-01-11T11:47:31Z", 20 | "acceptedTermsAndConditionsVersion": "PorscheConnectTAC/SE/sv_SE/v1.3", 21 | "preferredDealerId": "x8x42820-a9ff-11e8-917b-bb5x3xb96x6x", 22 | "dateOfBirth": "19771108", 23 | "dealers": ["88642820-a9ff-11e8-917b-bbx7xxl96c64"], 24 | "firstName": "Test", 25 | "identityVerificationState": "UNVERIFIED", 26 | "lastName": "User", 27 | "language": "sv-se", 28 | "localizedFirstName": "Test", 29 | "localizedLastName": "User", 30 | "mbbUid": "xxJrvdmuxxkbEwG61xW65zphiix", 31 | "mbbNames": ["MBB-DE"], 32 | "salutation": "0001", 33 | "salutationValue": "Herr", 34 | "status": "CONFIRMED", 35 | "preferredCountry": "SE", 36 | "addresses": [ 37 | { 38 | "addressId": "axxexnudxxdxmxbx", 39 | "ciamId": "xxghexxcbdwxyxuv", 40 | "usage": "home", 41 | "street1": "Street", 42 | "houseNumber": "3", 43 | "city": "city", 44 | "postalCode": "000000", 45 | "postalCodeRegion": "003", 46 | "postalCodeRegionValue": "Region", 47 | "country": "SE", 48 | "isStandard": true, 49 | "isExpressBuyAddress": false, 50 | "isBillingAddress": false, 51 | "isDeliveryAddress": false 52 | } 53 | ], 54 | "emails": [ 55 | { 56 | "emailId": "vwsinxmbopjzxxhkx", 57 | "email": "testuser@testemail.com", 58 | "isVerified": true, 59 | "isPending": false, 60 | "isStandard": true, 61 | "usage": "home" 62 | } 63 | ], 64 | "mobiles": [ 65 | { 66 | "mobileId": "dlmtdyxrdddkbexv", 67 | "number": "+4623453543", 68 | "isVerified": true, 69 | "isPending": false, 70 | "isStandard": true, 71 | "usage": "home" 72 | } 73 | ], 74 | "phones": [], 75 | "vehicles": [ 76 | { 77 | "vin": "WPTAYCAN", 78 | "state": "N", 79 | "ownershipType": "owner", 80 | "invitedBy": "xx642820-a9ff-11e8-917b-bxxxxxexxcxx", 81 | "pcc": true, 82 | "confirmed": true, 83 | "validFrom": "2021-01-19T13:28:19Z" 84 | } 85 | ] 86 | }, 87 | "https://api.porsche.com/core/api/v2/se/sv_SE/vehicles/WPTAYCAN/permissions": { 88 | "userIsActive": true, 89 | "userRoleStatus": "ENABLED" 90 | }, 91 | "https://api.porsche.com/service-vehicle/service-access/WPTAYCAN/details": { 92 | "vehicleServiceEnabledMap": { 93 | "RAH": "HIDDEN", 94 | "RPC": "ENABLED", 95 | "CF": "ENABLED", 96 | "CN": "ENABLED", 97 | "ORU": "ENABLED", 98 | "RBC": "ENABLED", 99 | "TAP": "ENABLED", 100 | "GF": "ENABLED", 101 | "RTS": "ENABLED", 102 | "SA": "ENABLED", 103 | "CC": "ENABLED", 104 | "VSR": "DISABLED" 105 | }, 106 | "serviceAccessDetails": [ 107 | { 108 | "serviceId": "rclima_v1", 109 | "disabled": false, 110 | "disabledReason": null, 111 | "dependingServices": ["RPC", "RBC"], 112 | "actionUrl": null, 113 | "isConfigurable": null 114 | }, 115 | { 116 | "serviceId": "statusreport_v1", 117 | "disabled": false, 118 | "disabledReason": null, 119 | "dependingServices": ["CC"], 120 | "actionUrl": null, 121 | "isConfigurable": null 122 | }, 123 | { 124 | "serviceId": "uota_v1", 125 | "disabled": false, 126 | "disabledReason": null, 127 | "dependingServices": ["ORU"], 128 | "actionUrl": null, 129 | "isConfigurable": null 130 | }, 131 | { 132 | "serviceId": "carfinder_v1", 133 | "disabled": false, 134 | "disabledReason": null, 135 | "dependingServices": ["CF"], 136 | "actionUrl": null, 137 | "isConfigurable": null 138 | }, 139 | { 140 | "serviceId": "rhonk_v1", 141 | "disabled": false, 142 | "disabledReason": null, 143 | "dependingServices": [], 144 | "actionUrl": null, 145 | "isConfigurable": null 146 | }, 147 | { 148 | "serviceId": "mond_applemusic_v1", 149 | "disabled": false, 150 | "disabledReason": null, 151 | "dependingServices": [], 152 | "actionUrl": null, 153 | "isConfigurable": null 154 | }, 155 | { 156 | "serviceId": "mond_applepodcasts_v1", 157 | "disabled": false, 158 | "disabledReason": null, 159 | "dependingServices": [], 160 | "actionUrl": null, 161 | "isConfigurable": null 162 | }, 163 | { 164 | "serviceId": "speedalert_v1", 165 | "disabled": false, 166 | "disabledReason": null, 167 | "dependingServices": ["SA"], 168 | "actionUrl": null, 169 | "isConfigurable": null 170 | }, 171 | { 172 | "serviceId": "rolesrights_authorization_v2", 173 | "disabled": false, 174 | "disabledReason": null, 175 | "dependingServices": [], 176 | "actionUrl": null, 177 | "isConfigurable": null 178 | }, 179 | { 180 | "serviceId": "rprofilesandtimer_v1", 181 | "disabled": false, 182 | "disabledReason": null, 183 | "dependingServices": [], 184 | "actionUrl": null, 185 | "isConfigurable": null 186 | }, 187 | { 188 | "serviceId": "dwap", 189 | "disabled": false, 190 | "disabledReason": null, 191 | "dependingServices": ["TAP"], 192 | "actionUrl": null, 193 | "isConfigurable": null 194 | }, 195 | { 196 | "serviceId": "trip_statistic_v1", 197 | "disabled": false, 198 | "disabledReason": null, 199 | "dependingServices": ["RTS"], 200 | "actionUrl": null, 201 | "isConfigurable": null 202 | }, 203 | { 204 | "serviceId": "rlu_v1", 205 | "disabled": false, 206 | "disabledReason": null, 207 | "dependingServices": ["CC"], 208 | "actionUrl": null, 209 | "isConfigurable": null 210 | }, 211 | { 212 | "serviceId": "geofence_v1", 213 | "disabled": false, 214 | "disabledReason": null, 215 | "dependingServices": ["GF"], 216 | "actionUrl": null, 217 | "isConfigurable": null 218 | }, 219 | { 220 | "serviceId": "rbatterycharge_v1", 221 | "disabled": false, 222 | "disabledReason": null, 223 | "dependingServices": ["RPC", "RBC"], 224 | "actionUrl": null, 225 | "isConfigurable": null 226 | }, 227 | { 228 | "serviceId": "nc_destsync_v2", 229 | "disabled": false, 230 | "disabledReason": null, 231 | "dependingServices": ["CN"], 232 | "actionUrl": null, 233 | "isConfigurable": null 234 | }, 235 | { 236 | "serviceId": "otafc_predmaint_v1", 237 | "disabled": false, 238 | "disabledReason": null, 239 | "dependingServices": [], 240 | "actionUrl": null, 241 | "isConfigurable": null 242 | } 243 | ] 244 | }, 245 | "https://api.porsche.com/service-vehicle/de/de_DE/vehicle-data/WPTAYCAN/current/request/abc/status": { 246 | "actionState": "OK" 247 | }, 248 | "https://api.porsche.com/service-vehicle/de/de_DE/vehicle-data/WPTAYCAN/current/request/abc": { 249 | "vin": "WPTAYCAN", 250 | "oilLevel": null, 251 | "fuelLevel": null, 252 | "batteryLevel": { 253 | "value": 96, 254 | "unit": "PERCENT", 255 | "unitTranslationKey": "GRAY_SLICE_UNIT_PERCENT" 256 | }, 257 | "remainingRanges": { 258 | "conventionalRange": { 259 | "distance": null, 260 | "engineType": "UNSUPPORTED", 261 | "isPrimary": false 262 | }, 263 | "electricalRange": { 264 | "distance": { 265 | "value": 348, 266 | "unit": "KILOMETERS", 267 | "originalValue": 348, 268 | "originalUnit": "KILOMETERS", 269 | "valueInKilometers": 348, 270 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 271 | }, 272 | "engineType": "ELECTRIC", 273 | "isPrimary": true 274 | } 275 | }, 276 | "mileage": { 277 | "value": 13247, 278 | "unit": "KILOMETERS", 279 | "originalValue": 13247, 280 | "originalUnit": "KILOMETERS", 281 | "valueInKilometers": 13247, 282 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 283 | }, 284 | "parkingLight": "OFF", 285 | "parkingLightStatus": null, 286 | "parkingBreak": "ACTIVE", 287 | "parkingBreakStatus": null, 288 | "doors": { 289 | "frontLeft": "CLOSED_LOCKED", 290 | "frontRight": "CLOSED_LOCKED", 291 | "backLeft": "CLOSED_LOCKED", 292 | "backRight": "CLOSED_LOCKED", 293 | "frontTrunk": "CLOSED_UNLOCKED", 294 | "backTrunk": "CLOSED_LOCKED", 295 | "overallLockStatus": "CLOSED_LOCKED" 296 | }, 297 | "serviceIntervals": { 298 | "oilService": { "distance": null, "time": null }, 299 | "inspection": { 300 | "distance": { 301 | "value": -16800, 302 | "unit": "KILOMETERS", 303 | "originalValue": -16800, 304 | "originalUnit": "KILOMETERS", 305 | "valueInKilometers": -16800, 306 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 307 | }, 308 | "time": { 309 | "value": -415, 310 | "unit": "DAYS", 311 | "unitTranslationKey": "GRAY_SLICE_UNIT_DAY" 312 | } 313 | } 314 | }, 315 | "tires": { 316 | "frontLeft": { 317 | "currentPressure": { 318 | "value": 3, 319 | "unit": "BAR", 320 | "valueInBar": 3, 321 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 322 | }, 323 | "optimalPressure": { 324 | "value": 3.3, 325 | "unit": "BAR", 326 | "valueInBar": 3.3, 327 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 328 | }, 329 | "differencePressure": { 330 | "value": 0.3, 331 | "unit": "BAR", 332 | "valueInBar": 0.3, 333 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 334 | }, 335 | "tirePressureDifferenceStatus": "DIVERGENT" 336 | }, 337 | "frontRight": { 338 | "currentPressure": { 339 | "value": 3, 340 | "unit": "BAR", 341 | "valueInBar": 3, 342 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 343 | }, 344 | "optimalPressure": { 345 | "value": 3.3, 346 | "unit": "BAR", 347 | "valueInBar": 3.3, 348 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 349 | }, 350 | "differencePressure": { 351 | "value": 0.3, 352 | "unit": "BAR", 353 | "valueInBar": 0.3, 354 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 355 | }, 356 | "tirePressureDifferenceStatus": "DIVERGENT" 357 | }, 358 | "backLeft": { 359 | "currentPressure": { 360 | "value": 3.2, 361 | "unit": "BAR", 362 | "valueInBar": 3.2, 363 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 364 | }, 365 | "optimalPressure": { 366 | "value": 3.8, 367 | "unit": "BAR", 368 | "valueInBar": 3.8, 369 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 370 | }, 371 | "differencePressure": { 372 | "value": 0.6, 373 | "unit": "BAR", 374 | "valueInBar": 0.6, 375 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 376 | }, 377 | "tirePressureDifferenceStatus": "DIVERGENT" 378 | }, 379 | "backRight": { 380 | "currentPressure": { 381 | "value": 3.2, 382 | "unit": "BAR", 383 | "valueInBar": 3.2, 384 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 385 | }, 386 | "optimalPressure": { 387 | "value": 3.8, 388 | "unit": "BAR", 389 | "valueInBar": 3.8, 390 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 391 | }, 392 | "differencePressure": { 393 | "value": 0.6, 394 | "unit": "BAR", 395 | "valueInBar": 0.6, 396 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 397 | }, 398 | "tirePressureDifferenceStatus": "DIVERGENT" 399 | } 400 | }, 401 | "windows": { 402 | "frontLeft": "CLOSED", 403 | "frontRight": "CLOSED", 404 | "backLeft": "CLOSED", 405 | "backRight": "CLOSED", 406 | "roof": "UNSUPPORTED", 407 | "maintenanceHatch": "UNSUPPORTED", 408 | "sunroof": { "status": "UNSUPPORTED", "positionInPercent": null } 409 | }, 410 | "parkingTime": "16.11.2021 14:17:03", 411 | "overallOpenStatus": "CLOSED" 412 | }, 413 | "https://api.porsche.com/core/api/v3/de/de_DE/vehicles": [ 414 | { 415 | "vin": "WPTAYCAN", 416 | "isPcc": true, 417 | "relationship": "OWNER", 418 | "maxSecondaryUsers": 5, 419 | "modelDescription": "Taycan Turbo S", 420 | "modelType": "Y1AFH1", 421 | "modelYear": "2021", 422 | "exteriorColor": "Ice Grey Metallic/Ice Grey Metallic", 423 | "exteriorColorHex": "#2d1746", 424 | "spinEnabled": true, 425 | "loginMethod": "PORSCHE_ID", 426 | "pictures": [], 427 | "attributes": [], 428 | "validFrom": "2021-01-15T15:38:03.000Z" 429 | } 430 | ], 431 | "https://api.porsche.com/service-vehicle/vehicle-summary/WPTAYCAN": { 432 | "modelDescription": "Taycan Turbo S", 433 | "nickName": null 434 | }, 435 | "https://api.porsche.com/service-vehicle/vcs/capabilities/WPTAYCAN": { 436 | "displayParkingBrake": true, 437 | "needsSPIN": true, 438 | "hasRDK": true, 439 | "engineType": "BEV", 440 | "carModel": "J1", 441 | "onlineRemoteUpdateStatus": { "editableByUser": true, "active": true }, 442 | "heatingCapabilities": { 443 | "frontSeatHeatingAvailable": true, 444 | "rearSeatHeatingAvailable": true 445 | }, 446 | "steeringWheelPosition": "LEFT", 447 | "hasHonkAndFlash": true 448 | }, 449 | "https://api.porsche.com/service-vehicle/car-finder/WPTAYCAN/position": { 450 | "carCoordinate": { 451 | "geoCoordinateSystem": "WGS84", 452 | "latitude": 63.883564, 453 | "longitude": 12.884809 454 | }, 455 | "heading": 273 456 | }, 457 | "https://api.porsche.com/e-mobility/de/de_DE/J1/WPTAYCAN?timezone=Europe/Stockholm": { 458 | "batteryChargeStatus": { 459 | "plugState": "DISCONNECTED", 460 | "lockState": "UNLOCKED", 461 | "chargingState": "OFF", 462 | "chargingReason": "INVALID", 463 | "externalPowerSupplyState": "UNAVAILABLE", 464 | "ledColor": "NONE", 465 | "ledState": "OFF", 466 | "chargingMode": "OFF", 467 | "stateOfChargeInPercentage": 96, 468 | "remainingChargeTimeUntil100PercentInMinutes": null, 469 | "remainingERange": { 470 | "value": 348, 471 | "unit": "KILOMETERS", 472 | "originalValue": 348, 473 | "originalUnit": "KILOMETERS", 474 | "valueInKilometers": 348, 475 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 476 | }, 477 | "remainingCRange": null, 478 | "chargingTargetDateTime": "2021-11-12T15:35", 479 | "status": null, 480 | "chargeRate": { 481 | "value": 0, 482 | "unit": "KM_PER_MIN", 483 | "valueInKmPerHour": 0, 484 | "unitTranslationKey": "EM.COMMON.UNIT.KM_PER_MIN" 485 | }, 486 | "chargingPower": 0, 487 | "chargingTargetDateTimeOplEnforced": null, 488 | "chargingInDCMode": false 489 | }, 490 | "directCharge": { "disabled": true, "isActive": false }, 491 | "directClimatisation": { 492 | "climatisationState": "OFF", 493 | "remainingClimatisationTime": null 494 | }, 495 | "chargingStatus": "NOT_CHARGING", 496 | "timers": [ 497 | { 498 | "timerID": "1", 499 | "departureDateTime": "2021-11-17T21:18:00.000Z", 500 | "preferredChargingTimeEnabled": false, 501 | "preferredChargingStartTime": null, 502 | "preferredChargingEndTime": null, 503 | "frequency": "SINGLE", 504 | "climatised": true, 505 | "weekDays": null, 506 | "active": false, 507 | "chargeOption": true, 508 | "targetChargeLevel": 85, 509 | "e3_CLIMATISATION_TIMER_ID": "4", 510 | "climatisationTimer": false 511 | } 512 | ], 513 | "climateTimer": null, 514 | "chargingProfiles": { 515 | "currentProfileId": 0, 516 | "profiles": [ 517 | { 518 | "profileId": 4, 519 | "profileName": "Allgemein", 520 | "profileActive": false, 521 | "chargingOptions": { 522 | "minimumChargeLevel": 25, 523 | "smartChargingEnabled": true, 524 | "preferredChargingEnabled": false, 525 | "preferredChargingTimeStart": "00:00", 526 | "preferredChargingTimeEnd": "06:00" 527 | }, 528 | "position": { "latitude": 0, "longitude": 0 } 529 | } 530 | ] 531 | }, 532 | "errorInfo": [] 533 | }, 534 | "https://api.porsche.com/service-vehicle/de/de_DE/vehicle-data/WPTAYCAN/stored": { 535 | "vin": "WPTAYCAN", 536 | "oilLevel": null, 537 | "fuelLevel": null, 538 | "batteryLevel": { 539 | "value": 96, 540 | "unit": "PERCENT", 541 | "unitTranslationKey": "GRAY_SLICE_UNIT_PERCENT" 542 | }, 543 | "remainingRanges": { 544 | "conventionalRange": { 545 | "distance": null, 546 | "engineType": "UNSUPPORTED", 547 | "isPrimary": false 548 | }, 549 | "electricalRange": { 550 | "distance": { 551 | "value": 348, 552 | "unit": "KILOMETERS", 553 | "originalValue": 348, 554 | "originalUnit": "KILOMETERS", 555 | "valueInKilometers": 348, 556 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 557 | }, 558 | "engineType": "ELECTRIC", 559 | "isPrimary": true 560 | } 561 | }, 562 | "mileage": { 563 | "value": 13247, 564 | "unit": "KILOMETERS", 565 | "originalValue": 13247, 566 | "originalUnit": "KILOMETERS", 567 | "valueInKilometers": 13247, 568 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 569 | }, 570 | "parkingLight": "OFF", 571 | "parkingLightStatus": null, 572 | "parkingBreak": "ACTIVE", 573 | "parkingBreakStatus": null, 574 | "doors": { 575 | "frontLeft": "CLOSED_LOCKED", 576 | "frontRight": "CLOSED_LOCKED", 577 | "backLeft": "CLOSED_LOCKED", 578 | "backRight": "CLOSED_LOCKED", 579 | "frontTrunk": "CLOSED_UNLOCKED", 580 | "backTrunk": "CLOSED_LOCKED", 581 | "overallLockStatus": "CLOSED_LOCKED" 582 | }, 583 | "serviceIntervals": { 584 | "oilService": { "distance": null, "time": null }, 585 | "inspection": { 586 | "distance": { 587 | "value": -16800, 588 | "unit": "KILOMETERS", 589 | "originalValue": -16800, 590 | "originalUnit": "KILOMETERS", 591 | "valueInKilometers": -16800, 592 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 593 | }, 594 | "time": { 595 | "value": -415, 596 | "unit": "DAYS", 597 | "unitTranslationKey": "GRAY_SLICE_UNIT_DAY" 598 | } 599 | } 600 | }, 601 | "tires": { 602 | "frontLeft": { 603 | "currentPressure": { 604 | "value": 3, 605 | "unit": "BAR", 606 | "valueInBar": 3, 607 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 608 | }, 609 | "optimalPressure": { 610 | "value": 3.3, 611 | "unit": "BAR", 612 | "valueInBar": 3.3, 613 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 614 | }, 615 | "differencePressure": { 616 | "value": 0.3, 617 | "unit": "BAR", 618 | "valueInBar": 0.3, 619 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 620 | }, 621 | "tirePressureDifferenceStatus": "DIVERGENT" 622 | }, 623 | "frontRight": { 624 | "currentPressure": { 625 | "value": 3, 626 | "unit": "BAR", 627 | "valueInBar": 3, 628 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 629 | }, 630 | "optimalPressure": { 631 | "value": 3.3, 632 | "unit": "BAR", 633 | "valueInBar": 3.3, 634 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 635 | }, 636 | "differencePressure": { 637 | "value": 0.3, 638 | "unit": "BAR", 639 | "valueInBar": 0.3, 640 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 641 | }, 642 | "tirePressureDifferenceStatus": "DIVERGENT" 643 | }, 644 | "backLeft": { 645 | "currentPressure": { 646 | "value": 3.2, 647 | "unit": "BAR", 648 | "valueInBar": 3.2, 649 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 650 | }, 651 | "optimalPressure": { 652 | "value": 3.8, 653 | "unit": "BAR", 654 | "valueInBar": 3.8, 655 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 656 | }, 657 | "differencePressure": { 658 | "value": 0.6, 659 | "unit": "BAR", 660 | "valueInBar": 0.6, 661 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 662 | }, 663 | "tirePressureDifferenceStatus": "DIVERGENT" 664 | }, 665 | "backRight": { 666 | "currentPressure": { 667 | "value": 3.2, 668 | "unit": "BAR", 669 | "valueInBar": 3.2, 670 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 671 | }, 672 | "optimalPressure": { 673 | "value": 3.8, 674 | "unit": "BAR", 675 | "valueInBar": 3.8, 676 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 677 | }, 678 | "differencePressure": { 679 | "value": 0.6, 680 | "unit": "BAR", 681 | "valueInBar": 0.6, 682 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 683 | }, 684 | "tirePressureDifferenceStatus": "DIVERGENT" 685 | } 686 | }, 687 | "windows": { 688 | "frontLeft": "CLOSED", 689 | "frontRight": "CLOSED", 690 | "backLeft": "CLOSED", 691 | "backRight": "CLOSED", 692 | "roof": "UNSUPPORTED", 693 | "maintenanceHatch": "UNSUPPORTED", 694 | "sunroof": { "status": "UNSUPPORTED", "positionInPercent": null } 695 | }, 696 | "parkingTime": "16.11.2021 14:17:03", 697 | "overallOpenStatus": "CLOSED" 698 | } 699 | } 700 | } 701 | -------------------------------------------------------------------------------- /tests/fixtures/taycan_privacy.json: -------------------------------------------------------------------------------- 1 | { 2 | "POST": { 3 | "https://api.porsche.com/service-vehicle/de/de_DE/vehicle-data/WPTAYCAN/current/request": { 4 | "requestId": "abc" 5 | } 6 | }, 7 | "GET": { 8 | "https://api.porsche.com/core/api/v3/de/de_DE/services?WPTAYCAN": { 9 | "settingsUrl": "", 10 | "services": [] 11 | }, 12 | "https://api.porsche.com/profiles/mydata?country=de": { 13 | "ciamId": "xxghxxxcbdwxyxuv", 14 | "porscheId": "testuser@testemail.com", 15 | "acceptedDataPrivacyDate": "2022-01-05T07:37:58Z", 16 | "acceptedDataPrivacyVersion": "PorscheConnectDPG/SE/sv_SE/v3.8", 17 | "acceptedDealerConsentDate": "2021-01-11T11:47:31Z", 18 | "acceptedDealerConsentVersion": "PorscheConnectDPG_DC/SE/sv_SE/v2.0", 19 | "acceptedTermsAndConditionsDate": "2021-01-11T11:47:31Z", 20 | "acceptedTermsAndConditionsVersion": "PorscheConnectTAC/SE/sv_SE/v1.3", 21 | "preferredDealerId": "x8x42820-a9ff-11e8-917b-bb5x3xb96x6x", 22 | "dateOfBirth": "19771108", 23 | "dealers": ["88642820-a9ff-11e8-917b-bbx7xxl96c64"], 24 | "firstName": "Test", 25 | "identityVerificationState": "UNVERIFIED", 26 | "lastName": "User", 27 | "language": "sv-se", 28 | "localizedFirstName": "Test", 29 | "localizedLastName": "User", 30 | "mbbUid": "xxJrvdmuxxkbEwG61xW65zphiix", 31 | "mbbNames": ["MBB-DE"], 32 | "salutation": "0001", 33 | "salutationValue": "Herr", 34 | "status": "CONFIRMED", 35 | "preferredCountry": "SE", 36 | "addresses": [ 37 | { 38 | "addressId": "axxexnudxxdxmxbx", 39 | "ciamId": "xxghexxcbdwxyxuv", 40 | "usage": "home", 41 | "street1": "Street", 42 | "houseNumber": "3", 43 | "city": "city", 44 | "postalCode": "000000", 45 | "postalCodeRegion": "003", 46 | "postalCodeRegionValue": "Region", 47 | "country": "SE", 48 | "isStandard": true, 49 | "isExpressBuyAddress": false, 50 | "isBillingAddress": false, 51 | "isDeliveryAddress": false 52 | } 53 | ], 54 | "emails": [ 55 | { 56 | "emailId": "vwsinxmbopjzxxhkx", 57 | "email": "testuser@testemail.com", 58 | "isVerified": true, 59 | "isPending": false, 60 | "isStandard": true, 61 | "usage": "home" 62 | } 63 | ], 64 | "mobiles": [ 65 | { 66 | "mobileId": "dlmtdyxrdddkbexv", 67 | "number": "+4623453543", 68 | "isVerified": true, 69 | "isPending": false, 70 | "isStandard": true, 71 | "usage": "home" 72 | } 73 | ], 74 | "phones": [], 75 | "vehicles": [ 76 | { 77 | "vin": "WPTAYCAN", 78 | "state": "N", 79 | "ownershipType": "owner", 80 | "invitedBy": "xx642820-a9ff-11e8-917b-bxxxxxexxcxx", 81 | "pcc": true, 82 | "confirmed": true, 83 | "validFrom": "2021-01-19T13:28:19Z" 84 | } 85 | ] 86 | }, 87 | "https://api.porsche.com/core/api/v2/se/sv_SE/vehicles/WPTAYCAN/permissions": { 88 | "userIsActive": true, 89 | "userRoleStatus": "ENABLED" 90 | }, 91 | "https://api.porsche.com/service-vehicle/service-access/WPTAYCAN/details": { 92 | "vehicleServiceEnabledMap": { 93 | "RAH": "HIDDEN", 94 | "RPC": "DISABLED", 95 | "CF": "DISABLED", 96 | "CN": "DISABLED", 97 | "ORU": "DISABLED", 98 | "RBC": "DISABLED", 99 | "TAP": "DISABLED", 100 | "GF": "DISABLED", 101 | "RTS": "DISABLED", 102 | "VSR": "ENABLED", 103 | "SA": "DISABLED", 104 | "CC": "ENABLED" 105 | }, 106 | "serviceAccessDetails": [ 107 | { 108 | "serviceId": "rclima_v1", 109 | "disabled": false, 110 | "disabledReason": null, 111 | "dependingServices": ["RPC", "RBC"], 112 | "actionUrl": null, 113 | "isConfigurable": null 114 | }, 115 | { 116 | "serviceId": "statusreport_v1", 117 | "disabled": false, 118 | "disabledReason": null, 119 | "dependingServices": ["CC"], 120 | "actionUrl": null, 121 | "isConfigurable": null 122 | }, 123 | { 124 | "serviceId": "uota_v1", 125 | "disabled": true, 126 | "disabledReason": "PRIVACY_MODE", 127 | "dependingServices": ["ORU"], 128 | "actionUrl": null, 129 | "isConfigurable": null 130 | }, 131 | { 132 | "serviceId": "carfinder_v1", 133 | "disabled": true, 134 | "disabledReason": "PRIVACY_MODE", 135 | "dependingServices": ["CF"], 136 | "actionUrl": null, 137 | "isConfigurable": null 138 | }, 139 | { 140 | "serviceId": "rhonk_v1", 141 | "disabled": false, 142 | "disabledReason": null, 143 | "dependingServices": [], 144 | "actionUrl": null, 145 | "isConfigurable": null 146 | }, 147 | { 148 | "serviceId": "mond_applemusic_v1", 149 | "disabled": false, 150 | "disabledReason": null, 151 | "dependingServices": [], 152 | "actionUrl": null, 153 | "isConfigurable": null 154 | }, 155 | { 156 | "serviceId": "mond_applepodcasts_v1", 157 | "disabled": false, 158 | "disabledReason": null, 159 | "dependingServices": [], 160 | "actionUrl": null, 161 | "isConfigurable": null 162 | }, 163 | { 164 | "serviceId": "speedalert_v1", 165 | "disabled": false, 166 | "disabledReason": null, 167 | "dependingServices": ["SA"], 168 | "actionUrl": null, 169 | "isConfigurable": null 170 | }, 171 | { 172 | "serviceId": "rolesrights_authorization_v2", 173 | "disabled": false, 174 | "disabledReason": null, 175 | "dependingServices": [], 176 | "actionUrl": null, 177 | "isConfigurable": null 178 | }, 179 | { 180 | "serviceId": "rprofilesandtimer_v1", 181 | "disabled": false, 182 | "disabledReason": null, 183 | "dependingServices": [], 184 | "actionUrl": null, 185 | "isConfigurable": null 186 | }, 187 | { 188 | "serviceId": "dwap", 189 | "disabled": false, 190 | "disabledReason": null, 191 | "dependingServices": ["TAP"], 192 | "actionUrl": null, 193 | "isConfigurable": null 194 | }, 195 | { 196 | "serviceId": "trip_statistic_v1", 197 | "disabled": false, 198 | "disabledReason": null, 199 | "dependingServices": ["RTS"], 200 | "actionUrl": null, 201 | "isConfigurable": null 202 | }, 203 | { 204 | "serviceId": "rlu_v1", 205 | "disabled": false, 206 | "disabledReason": null, 207 | "dependingServices": ["CC"], 208 | "actionUrl": null, 209 | "isConfigurable": null 210 | }, 211 | { 212 | "serviceId": "geofence_v1", 213 | "disabled": false, 214 | "disabledReason": null, 215 | "dependingServices": ["GF"], 216 | "actionUrl": null, 217 | "isConfigurable": null 218 | }, 219 | { 220 | "serviceId": "rbatterycharge_v1", 221 | "disabled": false, 222 | "disabledReason": null, 223 | "dependingServices": ["RPC", "RBC"], 224 | "actionUrl": null, 225 | "isConfigurable": null 226 | }, 227 | { 228 | "serviceId": "nc_destsync_v2", 229 | "disabled": false, 230 | "disabledReason": null, 231 | "dependingServices": ["CN"], 232 | "actionUrl": null, 233 | "isConfigurable": null 234 | }, 235 | { 236 | "serviceId": "otafc_predmaint_v1", 237 | "disabled": false, 238 | "disabledReason": null, 239 | "dependingServices": [], 240 | "actionUrl": null, 241 | "isConfigurable": null 242 | } 243 | ] 244 | }, 245 | "https://api.porsche.com/service-vehicle/de/de_DE/vehicle-data/WPTAYCAN/current/request/abc/status": { 246 | "actionState": "OK" 247 | }, 248 | "https://api.porsche.com/service-vehicle/de/de_DE/vehicle-data/WPTAYCAN/current/request/abc": { 249 | "vin": "WPTAYCAN", 250 | "oilLevel": null, 251 | "fuelLevel": null, 252 | "batteryLevel": { 253 | "value": 96, 254 | "unit": "PERCENT", 255 | "unitTranslationKey": "GRAY_SLICE_UNIT_PERCENT" 256 | }, 257 | "remainingRanges": { 258 | "conventionalRange": { 259 | "distance": null, 260 | "engineType": "UNSUPPORTED", 261 | "isPrimary": false 262 | }, 263 | "electricalRange": { 264 | "distance": { 265 | "value": 348, 266 | "unit": "KILOMETERS", 267 | "originalValue": 348, 268 | "originalUnit": "KILOMETERS", 269 | "valueInKilometers": 348, 270 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 271 | }, 272 | "engineType": "ELECTRIC", 273 | "isPrimary": true 274 | } 275 | }, 276 | "mileage": { 277 | "value": 13247, 278 | "unit": "KILOMETERS", 279 | "originalValue": 13247, 280 | "originalUnit": "KILOMETERS", 281 | "valueInKilometers": 13247, 282 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 283 | }, 284 | "parkingLight": "OFF", 285 | "parkingLightStatus": null, 286 | "parkingBreak": "ACTIVE", 287 | "parkingBreakStatus": null, 288 | "doors": { 289 | "frontLeft": "CLOSED_LOCKED", 290 | "frontRight": "CLOSED_LOCKED", 291 | "backLeft": "CLOSED_LOCKED", 292 | "backRight": "CLOSED_LOCKED", 293 | "frontTrunk": "CLOSED_UNLOCKED", 294 | "backTrunk": "CLOSED_LOCKED", 295 | "overallLockStatus": "CLOSED_LOCKED" 296 | }, 297 | "serviceIntervals": { 298 | "oilService": { "distance": null, "time": null }, 299 | "inspection": { 300 | "distance": { 301 | "value": -16800, 302 | "unit": "KILOMETERS", 303 | "originalValue": -16800, 304 | "originalUnit": "KILOMETERS", 305 | "valueInKilometers": -16800, 306 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 307 | }, 308 | "time": { 309 | "value": -415, 310 | "unit": "DAYS", 311 | "unitTranslationKey": "GRAY_SLICE_UNIT_DAY" 312 | } 313 | } 314 | }, 315 | "tires": { 316 | "frontLeft": { 317 | "currentPressure": { 318 | "value": 3, 319 | "unit": "BAR", 320 | "valueInBar": 3, 321 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 322 | }, 323 | "optimalPressure": { 324 | "value": 3.3, 325 | "unit": "BAR", 326 | "valueInBar": 3.3, 327 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 328 | }, 329 | "differencePressure": { 330 | "value": 0.3, 331 | "unit": "BAR", 332 | "valueInBar": 0.3, 333 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 334 | }, 335 | "tirePressureDifferenceStatus": "DIVERGENT" 336 | }, 337 | "frontRight": { 338 | "currentPressure": { 339 | "value": 3, 340 | "unit": "BAR", 341 | "valueInBar": 3, 342 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 343 | }, 344 | "optimalPressure": { 345 | "value": 3.3, 346 | "unit": "BAR", 347 | "valueInBar": 3.3, 348 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 349 | }, 350 | "differencePressure": { 351 | "value": 0.3, 352 | "unit": "BAR", 353 | "valueInBar": 0.3, 354 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 355 | }, 356 | "tirePressureDifferenceStatus": "DIVERGENT" 357 | }, 358 | "backLeft": { 359 | "currentPressure": { 360 | "value": 3.2, 361 | "unit": "BAR", 362 | "valueInBar": 3.2, 363 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 364 | }, 365 | "optimalPressure": { 366 | "value": 3.8, 367 | "unit": "BAR", 368 | "valueInBar": 3.8, 369 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 370 | }, 371 | "differencePressure": { 372 | "value": 0.6, 373 | "unit": "BAR", 374 | "valueInBar": 0.6, 375 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 376 | }, 377 | "tirePressureDifferenceStatus": "DIVERGENT" 378 | }, 379 | "backRight": { 380 | "currentPressure": { 381 | "value": 3.2, 382 | "unit": "BAR", 383 | "valueInBar": 3.2, 384 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 385 | }, 386 | "optimalPressure": { 387 | "value": 3.8, 388 | "unit": "BAR", 389 | "valueInBar": 3.8, 390 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 391 | }, 392 | "differencePressure": { 393 | "value": 0.6, 394 | "unit": "BAR", 395 | "valueInBar": 0.6, 396 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 397 | }, 398 | "tirePressureDifferenceStatus": "DIVERGENT" 399 | } 400 | }, 401 | "windows": { 402 | "frontLeft": "CLOSED", 403 | "frontRight": "CLOSED", 404 | "backLeft": "CLOSED", 405 | "backRight": "CLOSED", 406 | "roof": "UNSUPPORTED", 407 | "maintenanceHatch": "UNSUPPORTED", 408 | "sunroof": { "status": "UNSUPPORTED", "positionInPercent": null } 409 | }, 410 | "parkingTime": "16.11.2021 14:17:03", 411 | "overallOpenStatus": "CLOSED" 412 | }, 413 | "https://api.porsche.com/core/api/v3/de/de_DE/vehicles": [ 414 | { 415 | "vin": "WPTAYCAN", 416 | "isPcc": true, 417 | "relationship": "OWNER", 418 | "maxSecondaryUsers": 5, 419 | "modelDescription": "Taycan Turbo S", 420 | "modelType": "Y1AFH1", 421 | "modelYear": "2021", 422 | "exteriorColor": "Ice Grey Metallic/Ice Grey Metallic", 423 | "exteriorColorHex": "#2d1746", 424 | "spinEnabled": true, 425 | "loginMethod": "PORSCHE_ID", 426 | "pictures": [], 427 | "attributes": [], 428 | "validFrom": "2021-01-15T15:38:03.000Z" 429 | } 430 | ], 431 | "https://api.porsche.com/service-vehicle/vehicle-summary/WPTAYCAN": { 432 | "modelDescription": "Taycan Turbo S", 433 | "nickName": null 434 | }, 435 | "https://api.porsche.com/service-vehicle/vcs/capabilities/WPTAYCAN": { 436 | "displayParkingBrake": true, 437 | "needsSPIN": true, 438 | "hasRDK": true, 439 | "engineType": "BEV", 440 | "carModel": "J1", 441 | "onlineRemoteUpdateStatus": { "editableByUser": true, "active": true }, 442 | "heatingCapabilities": { 443 | "frontSeatHeatingAvailable": true, 444 | "rearSeatHeatingAvailable": true 445 | }, 446 | "steeringWheelPosition": "LEFT", 447 | "hasHonkAndFlash": true 448 | }, 449 | "https://api.porsche.com/service-vehicle/car-finder/WPTAYCAN/position": { 450 | "carCoordinate": { 451 | "geoCoordinateSystem": "WGS84", 452 | "latitude": 63.883564, 453 | "longitude": 12.884809 454 | }, 455 | "heading": 273 456 | }, 457 | "https://api.porsche.com/e-mobility/de/de_DE/J1/WPTAYCAN?timezone=Europe/Stockholm": { 458 | "batteryChargeStatus": { 459 | "plugState": "DISCONNECTED", 460 | "lockState": "UNLOCKED", 461 | "chargingState": "OFF", 462 | "chargingReason": "INVALID", 463 | "externalPowerSupplyState": "UNAVAILABLE", 464 | "ledColor": "NONE", 465 | "ledState": "OFF", 466 | "chargingMode": "OFF", 467 | "stateOfChargeInPercentage": 96, 468 | "remainingChargeTimeUntil100PercentInMinutes": null, 469 | "remainingERange": { 470 | "value": 348, 471 | "unit": "KILOMETERS", 472 | "originalValue": 348, 473 | "originalUnit": "KILOMETERS", 474 | "valueInKilometers": 348, 475 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 476 | }, 477 | "remainingCRange": null, 478 | "chargingTargetDateTime": "2021-11-12T15:35", 479 | "status": null, 480 | "chargeRate": { 481 | "value": 0, 482 | "unit": "KM_PER_MIN", 483 | "valueInKmPerHour": 0, 484 | "unitTranslationKey": "EM.COMMON.UNIT.KM_PER_MIN" 485 | }, 486 | "chargingPower": 0, 487 | "chargingTargetDateTimeOplEnforced": null, 488 | "chargingInDCMode": false 489 | }, 490 | "directCharge": { "disabled": true, "isActive": false }, 491 | "directClimatisation": { 492 | "climatisationState": "OFF", 493 | "remainingClimatisationTime": null 494 | }, 495 | "chargingStatus": "NOT_CHARGING", 496 | "timers": [ 497 | { 498 | "timerID": "1", 499 | "departureDateTime": "2021-11-17T21:18:00.000Z", 500 | "preferredChargingTimeEnabled": false, 501 | "preferredChargingStartTime": null, 502 | "preferredChargingEndTime": null, 503 | "frequency": "SINGLE", 504 | "climatised": true, 505 | "weekDays": null, 506 | "active": false, 507 | "chargeOption": true, 508 | "targetChargeLevel": 85, 509 | "e3_CLIMATISATION_TIMER_ID": "4", 510 | "climatisationTimer": false 511 | } 512 | ], 513 | "climateTimer": null, 514 | "chargingProfiles": { 515 | "currentProfileId": 0, 516 | "profiles": [ 517 | { 518 | "profileId": 4, 519 | "profileName": "Allgemein", 520 | "profileActive": false, 521 | "chargingOptions": { 522 | "minimumChargeLevel": 25, 523 | "smartChargingEnabled": true, 524 | "preferredChargingEnabled": false, 525 | "preferredChargingTimeStart": "00:00", 526 | "preferredChargingTimeEnd": "06:00" 527 | }, 528 | "position": { "latitude": 0, "longitude": 0 } 529 | } 530 | ] 531 | }, 532 | "errorInfo": [] 533 | }, 534 | "https://api.porsche.com/service-vehicle/de/de_DE/vehicle-data/WPTAYCAN/stored": { 535 | "vin": "WPTAYCAN", 536 | "oilLevel": null, 537 | "fuelLevel": null, 538 | "batteryLevel": { 539 | "value": 96, 540 | "unit": "PERCENT", 541 | "unitTranslationKey": "GRAY_SLICE_UNIT_PERCENT" 542 | }, 543 | "remainingRanges": { 544 | "conventionalRange": { 545 | "distance": null, 546 | "engineType": "UNSUPPORTED", 547 | "isPrimary": false 548 | }, 549 | "electricalRange": { 550 | "distance": { 551 | "value": 348, 552 | "unit": "KILOMETERS", 553 | "originalValue": 348, 554 | "originalUnit": "KILOMETERS", 555 | "valueInKilometers": 348, 556 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 557 | }, 558 | "engineType": "ELECTRIC", 559 | "isPrimary": true 560 | } 561 | }, 562 | "mileage": { 563 | "value": 13247, 564 | "unit": "KILOMETERS", 565 | "originalValue": 13247, 566 | "originalUnit": "KILOMETERS", 567 | "valueInKilometers": 13247, 568 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 569 | }, 570 | "parkingLight": "OFF", 571 | "parkingLightStatus": null, 572 | "parkingBreak": "ACTIVE", 573 | "parkingBreakStatus": null, 574 | "doors": { 575 | "frontLeft": "CLOSED_LOCKED", 576 | "frontRight": "CLOSED_LOCKED", 577 | "backLeft": "CLOSED_LOCKED", 578 | "backRight": "CLOSED_LOCKED", 579 | "frontTrunk": "CLOSED_UNLOCKED", 580 | "backTrunk": "CLOSED_LOCKED", 581 | "overallLockStatus": "CLOSED_LOCKED" 582 | }, 583 | "serviceIntervals": { 584 | "oilService": { "distance": null, "time": null }, 585 | "inspection": { 586 | "distance": { 587 | "value": -16800, 588 | "unit": "KILOMETERS", 589 | "originalValue": -16800, 590 | "originalUnit": "KILOMETERS", 591 | "valueInKilometers": -16800, 592 | "unitTranslationKey": "GRAY_SLICE_UNIT_KILOMETER" 593 | }, 594 | "time": { 595 | "value": -415, 596 | "unit": "DAYS", 597 | "unitTranslationKey": "GRAY_SLICE_UNIT_DAY" 598 | } 599 | } 600 | }, 601 | "tires": { 602 | "frontLeft": { 603 | "currentPressure": { 604 | "value": 3, 605 | "unit": "BAR", 606 | "valueInBar": 3, 607 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 608 | }, 609 | "optimalPressure": { 610 | "value": 3.3, 611 | "unit": "BAR", 612 | "valueInBar": 3.3, 613 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 614 | }, 615 | "differencePressure": { 616 | "value": 0.3, 617 | "unit": "BAR", 618 | "valueInBar": 0.3, 619 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 620 | }, 621 | "tirePressureDifferenceStatus": "DIVERGENT" 622 | }, 623 | "frontRight": { 624 | "currentPressure": { 625 | "value": 3, 626 | "unit": "BAR", 627 | "valueInBar": 3, 628 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 629 | }, 630 | "optimalPressure": { 631 | "value": 3.3, 632 | "unit": "BAR", 633 | "valueInBar": 3.3, 634 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 635 | }, 636 | "differencePressure": { 637 | "value": 0.3, 638 | "unit": "BAR", 639 | "valueInBar": 0.3, 640 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 641 | }, 642 | "tirePressureDifferenceStatus": "DIVERGENT" 643 | }, 644 | "backLeft": { 645 | "currentPressure": { 646 | "value": 3.2, 647 | "unit": "BAR", 648 | "valueInBar": 3.2, 649 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 650 | }, 651 | "optimalPressure": { 652 | "value": 3.8, 653 | "unit": "BAR", 654 | "valueInBar": 3.8, 655 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 656 | }, 657 | "differencePressure": { 658 | "value": 0.6, 659 | "unit": "BAR", 660 | "valueInBar": 0.6, 661 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 662 | }, 663 | "tirePressureDifferenceStatus": "DIVERGENT" 664 | }, 665 | "backRight": { 666 | "currentPressure": { 667 | "value": 3.2, 668 | "unit": "BAR", 669 | "valueInBar": 3.2, 670 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 671 | }, 672 | "optimalPressure": { 673 | "value": 3.8, 674 | "unit": "BAR", 675 | "valueInBar": 3.8, 676 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 677 | }, 678 | "differencePressure": { 679 | "value": 0.6, 680 | "unit": "BAR", 681 | "valueInBar": 0.6, 682 | "unitTranslationKey": "GRAY_SLICE_UNIT_BAR" 683 | }, 684 | "tirePressureDifferenceStatus": "DIVERGENT" 685 | } 686 | }, 687 | "windows": { 688 | "frontLeft": "CLOSED", 689 | "frontRight": "CLOSED", 690 | "backLeft": "CLOSED", 691 | "backRight": "CLOSED", 692 | "roof": "UNSUPPORTED", 693 | "maintenanceHatch": "UNSUPPORTED", 694 | "sunroof": { "status": "UNSUPPORTED", "positionInPercent": null } 695 | }, 696 | "parkingTime": "16.11.2021 14:17:03", 697 | "overallOpenStatus": "CLOSED" 698 | } 699 | } 700 | } 701 | --------------------------------------------------------------------------------