Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ Connect subscription and are paired to a My Porsche account.
- Vehicle location via `device_tracker` (suppressed when privacy mode is on)
- Vehicle images (front, side, rear, rear-top, top views)
- Door `lock` entity (unlock requires the S-PIN configured per-entity)
- Remote `switch` entities: climatisation, direct charging
- `climate` entity: remote climatisation with a target temperature
- Remote `switch` entities: direct charging, and climatisation (disabled by default:
the `climate` entity sends the same command)
- `number` entity: target state of charge
- `button` entities: flash indicators, honk-and-flash, refresh overview
- `climatisation_start` service action with seat-heating zone selection
Expand Down Expand Up @@ -86,15 +88,17 @@ integration only creates entities that the car can actually drive.
| `device_tracker`| Vehicle location | `has_porsche_connect` and `privacy_mode == false` |
| `image` | `front_view`, `side_view`, `rear_view`, `rear_top_view`, `top_view`| Pictures returned by the API |
| `lock` | Door lock | `has_porsche_connect` (unlock needs S-PIN) |
| `switch` | `climatise` | `has_remote_climatisation` and `has_remote_services` |
| `climate` | Remote climatisation (target temperature) | `has_remote_climatisation` and `has_remote_services` |
| `switch` | `climatise` (disabled by default) | `has_remote_climatisation` and `has_remote_services` |
| `switch` | `direct_charging` | `has_direct_charge` and `has_remote_services` |
| `number` | `target_soc` (25–100 %, step 5) | `has_electric_drivetrain` and `has_remote_services` |
| `button` | `flash_indicators` | `has_remote_services` |
| `button` | `honk_and_flash_indicators` | `has_remote_services` |
| `button` | `get_current_overview` | `has_remote_services` (forces a fresh pull from the car) |

The privacy mode flag is honoured by the vehicle itself — when the driver
enables privacy in the PCM, the `device_tracker` entity is not created.
enables privacy in the PCM, the `device_tracker` entity is not created; it is
added on the next refresh once privacy mode is turned off.

## Installation

Expand Down Expand Up @@ -218,7 +222,7 @@ entities:
- entity: binary_sensor.taycan_doors_and_lids
- entity: binary_sensor.taycan_tire_pressure_status
- entity: lock.taycan_door_lock
- entity: switch.taycan_remote_climatisation
- entity: climate.taycan
- entity: number.taycan_target_state_of_charge
- entity: button.taycan_flash_indicators
```
Expand Down
102 changes: 44 additions & 58 deletions custom_components/porscheconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
import copy
import logging
import operator
from datetime import timedelta
from datetime import datetime, timedelta
from functools import reduce
from typing import Any
from typing import TYPE_CHECKING, Any

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
Expand All @@ -25,6 +25,7 @@
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.util import dt as dt_util
from pyporscheconnectapi.account import PorscheConnectAccount
from pyporscheconnectapi.connection import Connection
from pyporscheconnectapi.exceptions import (
Expand All @@ -36,13 +37,19 @@

from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS, TRANSIENT_AUTH_FIELDS

if TYPE_CHECKING:
import httpx2

_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL)

HTTP_UNAUTHORIZED = 401

_AUTH_FAILED_MSG = "Authentication failed for Porsche Connect"
_API_ERROR_MSG = "Error communicating with Porsche Connect API"

# Picture locations are cosmetic: when the API has none for a car, ask again
# once a day rather than on every refresh of a rate-limited API.
_PICTURE_RETRY_INTERVAL = timedelta(days=1)

# Type alias for the runtime data attached to each config entry.
PorscheConnectConfigEntry = ConfigEntry["PorscheConnectDataUpdateCoordinator"]
Expand Down Expand Up @@ -89,6 +96,19 @@ def _async_save_token(
)


@callback
def async_create_porsche_client(hass: HomeAssistant) -> httpx2.AsyncClient:
"""Return an HTTP client for the Porsche API.

The Porsche API returns zstd bodies that httpx2 (HA 2026.11+) cannot
decode, so the client must not advertise zstd. The header is set on the
client because create_async_httpx_client only merges headers= since 2026.2.
"""
client = create_async_httpx_client(hass)
client.headers["Accept-Encoding"] = "gzip, deflate"
return client


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Porsche Connect integration (domain-level).

Expand Down Expand Up @@ -157,15 +177,10 @@ async def async_setup_entry(
hass: HomeAssistant, entry: PorscheConnectConfigEntry
) -> bool:
"""Set up this integration using UI."""
# The Porsche API returns zstd bodies that httpx2 (HA 2026.11+) cannot decode,
# so use a dedicated client that does not advertise zstd. The header is set on
# the client because create_async_httpx_client only merges headers= since 2026.2.
async_client = create_async_httpx_client(hass)
async_client.headers["Accept-Encoding"] = "gzip, deflate"
connection = Connection(
entry.data.get("email"),
entry.data.get("password"),
async_client=async_client,
async_client=async_create_porsche_client(hass),
token=entry.data.get(CONF_ACCESS_TOKEN, None),
)

Expand All @@ -179,21 +194,9 @@ async def async_setup_entry(
controller=controller,
)

try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryAuthFailed:
# The coordinator already wrapped the auth error appropriately; let HA
# surface the reauth flow.
raise
except PorscheWrongCredentialsError as exc:
msg = f"{_AUTH_FAILED_MSG}: {exc}"
raise ConfigEntryAuthFailed(msg) from exc
except PorscheExceptionError as exc:
if _is_auth_error(exc):
msg = f"{_AUTH_FAILED_MSG}: {exc}"
raise ConfigEntryAuthFailed(msg) from exc
msg = f"{_API_ERROR_MSG}: {exc}"
raise ConfigEntryNotReady(msg) from exc
# The coordinator turns API errors into ConfigEntryAuthFailed (reauth) or
# UpdateFailed, which the first refresh raises as ConfigEntryNotReady.
await coordinator.async_config_entry_first_refresh()

entry.runtime_data = coordinator

Expand All @@ -213,11 +216,6 @@ async def async_setup_entry(
# rotated tokens.
_async_save_token(hass, entry, copy.deepcopy(controller.token))

# Track that this entry is using our domain-global services so that the
# last unload can release them. We keep this in hass.data only as a small
# bookkeeping set; entity state lives on entry.runtime_data.
hass.data.setdefault(DOMAIN, set()).add(entry.entry_id)

return True


Expand All @@ -234,6 +232,7 @@ def __init__(
self.controller = controller
self.vehicles: list[PorscheVehicle] = []
self.hass = hass
self._picture_attempts: dict[str, datetime] = {}

scan_interval = timedelta(
seconds=config_entry.options.get(
Expand All @@ -259,19 +258,23 @@ def get_vehicle_data_leaf(
"""Get data value leaf from dict."""
return get_from_dict(get_from_dict(vehicle.data, node), leaf)

# Backwards-compat alias (historic typo kept so external references don't
# break mid-upgrade). New code should use get_vehicle_data_leaf.
get_vechicle_data_leaf = get_vehicle_data_leaf

async def _async_fetch_pictures(self, vehicle: PorscheVehicle) -> None:
"""Fetch picture locations best-effort — cosmetic, must not fail a cycle."""
self._picture_attempts[vehicle.vin] = dt_util.utcnow()
try:
await vehicle.get_picture_locations()
except PorscheExceptionError as exc:
_LOGGER.debug(
"Could not fetch picture locations for %s: %s", vehicle.vin, exc,
)

def _picture_retry_due(self, vehicle: PorscheVehicle) -> bool:
"""Return True if empty picture locations should be fetched again."""
if vehicle.picture_locations:
return False
last = self._picture_attempts.get(vehicle.vin)
return last is None or dt_util.utcnow() - last >= _PICTURE_RETRY_INTERVAL

async def _async_update_data(self) -> dict:
"""Fetch data from API endpoint."""
try:
Expand All @@ -290,7 +293,7 @@ async def _async_update_data(self) -> dict:
# Retry pictures if the initial fetch came back empty
# (e.g. a transient failure at setup); the image platform
# adds the entities once they appear.
if not vehicle.picture_locations:
if self._picture_retry_due(vehicle):
await self._async_fetch_pictures(vehicle)

except PorscheWrongCredentialsError as exc:
Expand Down Expand Up @@ -320,33 +323,16 @@ async def _async_update_data(self) -> dict:
async def async_unload_entry(
hass: HomeAssistant, entry: PorscheConnectConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
"""Unload a config entry.

The domain-wide services stay registered: they are set up once in
async_setup, and a reload (e.g. after reauth) does not register them again.
"""
return await hass.config_entries.async_unload_platforms(
entry,
list(PLATFORMS),
)

if unload_ok:
tracked: set[str] = hass.data.get(DOMAIN, set())
tracked.discard(entry.entry_id)

# If this was the last entry, release the domain-global services so
# subsequent reloads don't accumulate stale registrations.
if not tracked:
from .services import async_unload_services

async_unload_services(hass)
hass.data.pop(DOMAIN, None)

return unload_ok


async def async_reload_entry(
hass: HomeAssistant, entry: PorscheConnectConfigEntry
) -> None:
"""Reload config entry."""
await hass.config_entries.async_reload(entry.entry_id)


async def async_remove_config_entry_device(
hass: HomeAssistant,
Expand Down
48 changes: 32 additions & 16 deletions custom_components/porscheconnect/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import base64
import logging
from typing import Any
from typing import TYPE_CHECKING, Any

import voluptuous as vol
from homeassistant import exceptions
Expand All @@ -24,6 +24,7 @@
PorscheWrongCredentialsError,
)

from . import async_create_porsche_client
from .const import (
CONF_CAPTCHA_CODE,
CONF_CODE_VERIFIER,
Expand All @@ -32,13 +33,18 @@
TRANSIENT_AUTH_FIELDS,
)

if TYPE_CHECKING:
import httpx2

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema({CONF_EMAIL: str, CONF_PASSWORD: str})
CHANGE_PASSWORD_SCHEMA = vol.Schema({CONF_PASSWORD: str})


async def validate_input(data: dict[str, Any]) -> dict[str, Any]:
async def validate_input(
client: httpx2.AsyncClient, data: dict[str, Any]
) -> dict[str, Any]:
"""Validate the user input allows us to connect.

Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
Expand All @@ -54,6 +60,7 @@ async def validate_input(data: dict[str, Any]) -> dict[str, Any]:
# come back with the captcha code, or the resume is rejected.
code_verifier=data.get(CONF_CODE_VERIFIER),
token=token,
async_client=client,
)
except PorscheExceptionError as exc:
_LOGGER.debug("Could not create connection: %s", exc)
Expand All @@ -63,7 +70,7 @@ async def validate_input(data: dict[str, Any]) -> dict[str, Any]:
try:
token = await conn.get_token()
except PorscheCaptchaRequiredError as exc:
_LOGGER.info("Captcha required to log in: %s", exc.captcha)
_LOGGER.info("Captcha required to log in")
return {
"email": data[CONF_EMAIL],
"password": data[CONF_PASSWORD],
Expand All @@ -75,11 +82,11 @@ async def validate_input(data: dict[str, Any]) -> dict[str, Any]:
_LOGGER.info("Wrong credentials.")
raise InvalidAuth from exc
except PorscheExceptionError as exc:
_LOGGER.info("Authentication flow error: %s", exc.message)
raise InvalidAuth from exc
_LOGGER.info("Could not log in to Porsche Connect: %s", exc.message)
raise CannotConnect from exc
except Exception as exc:
_LOGGER.info("Login failed: %s", exc)
raise InvalidAuth from exc
_LOGGER.exception("Unexpected error during login")
raise CannotConnect from exc

return {"title": data[CONF_EMAIL], CONF_ACCESS_TOKEN: token}

Expand All @@ -94,6 +101,14 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN):
captcha = None
state = None
code_verifier = None
_client: httpx2.AsyncClient | None = None

@callback
def _get_http_client(self) -> httpx2.AsyncClient:
"""Return the flow's HTTP client, shared by the login and captcha steps."""
if self._client is None:
self._client = async_create_porsche_client(self.hass)
return self._client

@callback
def _get_entry_for_current_flow(self) -> ConfigEntry | None:
Expand Down Expand Up @@ -169,7 +184,7 @@ async def async_step_user(
self._abort_if_unique_id_configured()

try:
info = await validate_input(user_input)
info = await validate_input(self._get_http_client(), user_input)
if info.get("captcha") and info.get(CONF_OAUTH_STATE):
self.email = info.get("email")
self.password = info.get("password")
Expand Down Expand Up @@ -206,6 +221,8 @@ async def async_step_user(

except InvalidAuth:
errors["base"] = "invalid_auth"
except CannotConnect:
errors["base"] = "cannot_connect"

return self.async_show_form(
step_id="user",
Expand All @@ -217,9 +234,6 @@ async def async_step_reauth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"],
)
await self.async_set_unique_id(
self._get_expected_unique_id(),
raise_on_progress=False,
Expand Down Expand Up @@ -272,7 +286,7 @@ async def async_step_captcha(
}
errors = {}
try:
info = await validate_input(user_input)
info = await validate_input(self._get_http_client(), user_input)

# Handle case where another captcha is required
if info.get("captcha") and info.get(CONF_OAUTH_STATE):
Expand Down Expand Up @@ -308,8 +322,10 @@ async def async_step_captcha(
data=entry_data,
)

except InvalidAuth:
errors["base"] = "invalid_auth"
except (InvalidAuth, CannotConnect) as err:
errors["base"] = (
"invalid_auth" if isinstance(err, InvalidAuth) else "cannot_connect"
)
return self.async_show_form(
step_id="captcha",
data_schema=vol.Schema(
Expand Down Expand Up @@ -362,5 +378,5 @@ class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""


class CaptchaRequired(exceptions.HomeAssistantError):
"""Error to indicate captcha verification is required."""
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate the Porsche login could not be completed."""
Loading
Loading