├── .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 |
--------------------------------------------------------------------------------