diff --git a/README.md b/README.md index 14e99a0..46016b6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -86,7 +88,8 @@ 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` | @@ -94,7 +97,8 @@ integration only creates entities that the car can actually drive. | `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 @@ -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 ``` diff --git a/custom_components/porscheconnect/__init__.py b/custom_components/porscheconnect/__init__.py index 680d584..e27b65f 100644 --- a/custom_components/porscheconnect/__init__.py +++ b/custom_components/porscheconnect/__init__.py @@ -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 @@ -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 ( @@ -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"] @@ -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). @@ -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), ) @@ -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 @@ -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 @@ -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( @@ -259,12 +258,9 @@ 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: @@ -272,6 +268,13 @@ async def _async_fetch_pictures(self, vehicle: PorscheVehicle) -> None: "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: @@ -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: @@ -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, diff --git a/custom_components/porscheconnect/config_flow.py b/custom_components/porscheconnect/config_flow.py index c109e37..5c416a5 100644 --- a/custom_components/porscheconnect/config_flow.py +++ b/custom_components/porscheconnect/config_flow.py @@ -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 @@ -24,6 +24,7 @@ PorscheWrongCredentialsError, ) +from . import async_create_porsche_client from .const import ( CONF_CAPTCHA_CODE, CONF_CODE_VERIFIER, @@ -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. @@ -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) @@ -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], @@ -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} @@ -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: @@ -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") @@ -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", @@ -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, @@ -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): @@ -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( @@ -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.""" diff --git a/custom_components/porscheconnect/const.py b/custom_components/porscheconnect/const.py index 71f461b..894fc5b 100644 --- a/custom_components/porscheconnect/const.py +++ b/custom_components/porscheconnect/const.py @@ -13,11 +13,6 @@ {CONF_CAPTCHA_CODE, CONF_CODE_VERIFIER, CONF_OAUTH_STATE}, ) -NAME = "porscheconnect" -DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "0.4.1" -ISSUE_URL = "https://github.com/cjne/ha-porscheconnect/issues" - PLATFORMS = [ "sensor", "binary_sensor", @@ -29,13 +24,3 @@ "image", "climate", ] - -STARTUP_MESSAGE = f""" -------------------------------------------------------------------- -{NAME} -Version: {VERSION} -This is a custom integration! -If you have any issues with this you need to open an issue here: -{ISSUE_URL} -------------------------------------------------------------------- -""" diff --git a/custom_components/porscheconnect/device_tracker.py b/custom_components/porscheconnect/device_tracker.py index 85a00b8..b84f53f 100644 --- a/custom_components/porscheconnect/device_tracker.py +++ b/custom_components/porscheconnect/device_tracker.py @@ -38,12 +38,14 @@ def _async_add_entities() -> None: for vehicle in coordinator.vehicles: if vehicle.vin in known_vins: continue - known_vins.add(vehicle.vin) + # Not marked as known while in privacy mode, so the tracker is + # added on a later refresh once privacy mode is turned off. if vehicle.privacy_mode: - _LOGGER.info( + _LOGGER.debug( "Vehicle is in privacy mode with location tracking disabled" ) continue + known_vins.add(vehicle.vin) new_entities.append(PorscheDeviceTracker(coordinator, vehicle)) if new_entities: async_add_entities(new_entities) diff --git a/custom_components/porscheconnect/diagnostics.py b/custom_components/porscheconnect/diagnostics.py index e777784..f7bd0df 100644 --- a/custom_components/porscheconnect/diagnostics.py +++ b/custom_components/porscheconnect/diagnostics.py @@ -39,6 +39,15 @@ "city", "postalCode", "zip", + # GPS_LOCATION.location is a "lat,lon" string, not separate coordinates. + "location", + # Whole measurements that hold key-pairing secrets or places. + "PAIRING_CODE", + "MDK_PAIRING_PASSWORD", + "BLEID_DDADATA", + "DESTINATIONS", + "LOCATION_ALARMS", + "VTS_CERTIFICATE_LIST", } # Local-part of an email is masked once it gets longer than this threshold. diff --git a/custom_components/porscheconnect/lock.py b/custom_components/porscheconnect/lock.py index f8d3635..97c4171 100644 --- a/custom_components/porscheconnect/lock.py +++ b/custom_components/porscheconnect/lock.py @@ -64,7 +64,6 @@ def __init__( super().__init__(coordinator, vehicle) self._attr_unique_id = f"{self._vin}-lock" - self.door_lock_state_available = vehicle.has_remote_services async def async_lock(self, **kwargs: Any) -> None: """Lock the vehicle.""" diff --git a/custom_components/porscheconnect/manifest.json b/custom_components/porscheconnect/manifest.json index 5de6508..e240608 100644 --- a/custom_components/porscheconnect/manifest.json +++ b/custom_components/porscheconnect/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/bartolije/ha-porscheconnect/issues", "quality_scale": "gold", - "requirements": ["pyporscheconnectapi-bartolije==0.5.0"], - "version": "0.4.1" + "requirements": ["pyporscheconnectapi-bartolije==0.5.1"], + "version": "0.4.2" } diff --git a/custom_components/porscheconnect/services.py b/custom_components/porscheconnect/services.py index 4f0221f..91b86e3 100644 --- a/custom_components/porscheconnect/services.py +++ b/custom_components/porscheconnect/services.py @@ -26,7 +26,7 @@ ATTR_REAR_RIGHT = "rear_right" # Mapping from service-call attribute (snake_case) to API zone key (camelCase), -# as exposed in `CLIMATIZER_STATE.climateZonesEnabled`. The set of keys actually +# as exposed in `HVAC_SUMMARY.climateZonesEnabled`. The set of keys actually # present in that payload is per-vehicle: zones the car does not physically have # are simply missing from the payload (not set to false). See issue #292. _ZONE_ATTR_TO_API_KEY: dict[str, str] = { @@ -47,11 +47,7 @@ def _resolve_climate_zone_kwargs( `ServiceValidationError` if the caller requested a zone the vehicle does not physically have (issue #292). """ - supported_zones: set[str] = set( - vehicle.data.get("CLIMATIZER_STATE", {}) - .get("climateZonesEnabled", {}) - .keys() - ) + supported_zones: set[str] = set(vehicle.seat_heating or {}) zone_kwargs: dict[str, bool] = {} for attr_name, api_key in _ZONE_ATTR_TO_API_KEY.items(): @@ -96,8 +92,6 @@ def _resolve_climate_zone_kwargs( SERVICE_CLIMATISATION_START = "climatisation_start" SERVICE_CLIMATISATION_STOP = "climatisation_stop" -SERVICES = (SERVICE_CLIMATISATION_START, SERVICE_CLIMATISATION_STOP) - def _resolve_vehicle( hass: HomeAssistant, service_call_data: Mapping @@ -160,7 +154,7 @@ async def climatisation_start(service_call: ServiceCall) -> None: ) # The Porsche API only includes zones the car physically supports - # in `CLIMATIZER_STATE.climateZonesEnabled`. A missing key means + # in `HVAC_SUMMARY.climateZonesEnabled`. A missing key means # "not available", not "false" — so derive the supported set from # the actual payload instead of hardcoding all four zones (#292). zone_kwargs = _resolve_climate_zone_kwargs(vehicle, service_call.data) @@ -234,11 +228,3 @@ async def climatisation_stop(service_call: ServiceCall) -> None: climatisation_stop, schema=SERVICE_VEHICLE_SCHEMA, ) - - -@callback -def async_unload_services(hass: HomeAssistant) -> None: - """Remove Porsche Connect services on last entry unload.""" - for service in SERVICES: - if hass.services.has_service(DOMAIN, service): - hass.services.async_remove(DOMAIN, service) diff --git a/custom_components/porscheconnect/switch.py b/custom_components/porscheconnect/switch.py index 065a180..5aa5e3a 100644 --- a/custom_components/porscheconnect/switch.py +++ b/custom_components/porscheconnect/switch.py @@ -37,6 +37,8 @@ class PorscheSwitchEntityDescription(SwitchEntityDescription): PorscheSwitchEntityDescription( key="climatise", translation_key="climatise", + # Same command as the climate entity, which also sets the temperature. + entity_registry_enabled_default=False, is_available=lambda v: v.has_remote_climatisation and v.has_remote_services, value_fn=lambda v: v.remote_climatise_on, remote_service_on=lambda v: v.remote_services.climatise_on(), diff --git a/requirements_test.txt b/requirements_test.txt index a15a3aa..ad34053 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pyporscheconnectapi-bartolije==0.5.0 +pyporscheconnectapi-bartolije==0.5.1 pytest-homeassistant==2026.11.0 pytest-timeout==2.4.0 pytest-cov==7.1.0 diff --git a/tests/conftest.py b/tests/conftest.py index ce72747..5d346a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,6 +48,9 @@ def _make_vehicle() -> SimpleNamespace: vehicle.has_porsche_connect = True vehicle.privacy_mode = False vehicle.picture_locations = {} + vehicle.seat_heating = None + vehicle.location = (None, None, None) + vehicle.location_updated_at = None vehicle.data = { "name": "Taycan", "modelName": "Taycan Turbo S", diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index c29c2ad..a8e5d74 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.data_entry_flow import FlowResultType from pyporscheconnectapi.exceptions import ( PorscheCaptchaRequiredError, + PorscheExceptionError, PorscheWrongCredentialsError, ) from pytest_homeassistant.common import MockConfigEntry @@ -88,6 +89,28 @@ async def test_user_step_wrong_credentials(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "invalid_auth"} +async def test_user_step_cannot_connect(hass: HomeAssistant) -> None: + """An API error that is not about the credentials → `cannot_connect`.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + instance = MagicMock() + instance.get_token = AsyncMock(side_effect=PorscheExceptionError(503)) + with patch( + "custom_components.porscheconnect.config_flow.Connection", + return_value=instance, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + async def test_user_step_already_configured(hass: HomeAssistant) -> None: """Submitting the same email twice → flow aborts with already_configured.""" existing = MockConfigEntry( @@ -221,12 +244,14 @@ async def test_captcha_step_resumes_with_code_verifier(hass: HomeAssistant) -> N with patch( "custom_components.porscheconnect.config_flow.Connection", return_value=challenged, - ): + ) as first_cls: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_EMAIL: TEST_EMAIL, CONF_PASSWORD: TEST_PASSWORD}, ) assert result["step_id"] == "captcha" + client = first_cls.call_args.kwargs["async_client"] + assert client.headers["Accept-Encoding"] == "gzip, deflate" with patch( "custom_components.porscheconnect.config_flow.Connection", @@ -239,6 +264,8 @@ async def test_captcha_step_resumes_with_code_verifier(hass: HomeAssistant) -> N await hass.async_block_till_done() kwargs = connection_cls.call_args.kwargs + # Same client as the first step: keeps the Auth0 transaction cookies. + assert kwargs["async_client"] is client assert kwargs[CONF_CAPTCHA_CODE] == "SOLVED" assert kwargs[CONF_OAUTH_STATE] == "state-token-xyz" assert kwargs[CONF_CODE_VERIFIER] == "verifier-abc" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 8568f02..cd4aa53 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -169,15 +170,16 @@ async def test_coordinator_captcha_required_triggers_reauth( await coordinator._async_update_data() -async def test_picture_locations_retried_when_empty( +async def test_picture_locations_retried_daily_when_empty( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_vehicle, + freezer, ) -> None: - """A periodic refresh retries get_picture_locations while it stays empty. + """Empty picture locations are fetched again once a day, not every refresh. Guards against losing the image entities when the one-shot fetch at setup - fails: picture_locations never populates here, so each refresh retries. + fails, without an extra call to a rate-limited API on every refresh. """ mock_config_entry.add_to_hass(hass) @@ -198,5 +200,8 @@ async def test_picture_locations_retried_when_empty( coordinator = mock_config_entry.runtime_data mock_vehicle.get_picture_locations.reset_mock() await coordinator._async_update_data() + mock_vehicle.get_picture_locations.assert_not_awaited() - mock_vehicle.get_picture_locations.assert_awaited() + freezer.tick(timedelta(days=1)) + await coordinator._async_update_data() + mock_vehicle.get_picture_locations.assert_awaited_once() diff --git a/tests/test_device_tracker.py b/tests/test_device_tracker.py new file mode 100644 index 0000000..bcd6b3e --- /dev/null +++ b/tests/test_device_tracker.py @@ -0,0 +1,32 @@ +"""Tests for the vehicle device tracker.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant.common import MockConfigEntry + +from custom_components.porscheconnect.const import DOMAIN + + +async def test_tracker_added_once_privacy_mode_is_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vehicle, + mock_connection_cls, + mock_account_cls, +) -> None: + """A car in privacy mode at startup gets its tracker when privacy ends.""" + mock_vehicle.privacy_mode = True + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + registry = er.async_get(hass) + assert registry.async_get_entity_id("device_tracker", DOMAIN, mock_vehicle.vin) is None + + mock_vehicle.privacy_mode = False + await mock_config_entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + assert registry.async_get_entity_id("device_tracker", DOMAIN, mock_vehicle.vin) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..cfb9efb --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,41 @@ +"""Tests for the config entry diagnostics.""" + +from __future__ import annotations + +import json + +from homeassistant.core import HomeAssistant +from pytest_homeassistant.common import MockConfigEntry + +from custom_components.porscheconnect.diagnostics import ( + async_get_config_entry_diagnostics, +) + + +async def test_diagnostics_redact_location_and_pairing_secrets( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vehicle, + mock_connection_cls, + mock_account_cls, +) -> None: + """Position, pairing codes and key data never reach the diagnostics file.""" + mock_vehicle.data.update( + { + "GPS_LOCATION": {"location": "48.85837,2.29448", "direction": 90}, + "PAIRING_CODE": {"value": "123456"}, + "BLEID_DDADATA": {"bleId": "AA:BB:CC", "ddaData": "key-material"}, + } + ) + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + dump = json.dumps( + await async_get_config_entry_diagnostics(hass, mock_config_entry), + default=str, + ) + + for secret in ("48.85837", "2.29448", "123456", "AA:BB:CC", "key-material"): + assert secret not in dump + assert mock_vehicle.vin not in dump diff --git a/tests/test_init.py b/tests/test_init.py index 3e52cd1..f7d6681 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -205,3 +205,21 @@ async def test_migrate_entry_drops_transient_auth_fields( assert entry.version == 2 assert TRANSIENT_AUTH_FIELDS.isdisjoint(entry.data) assert entry.data[CONF_ACCESS_TOKEN] == {"access_token": "abc123", "expires_in": 3600} + + +async def test_services_survive_entry_reload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connection_cls, + mock_account_cls, +) -> None: + """A reload (e.g. after reauth) keeps the domain services registered.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, "climatisation_start") + assert hass.services.has_service(DOMAIN, "climatisation_stop") diff --git a/tests/test_lock.py b/tests/test_lock.py index e8feeaa..0e7fe7e 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -39,16 +39,6 @@ def _make(coordinator, vehicle) -> PorscheLock: return PorscheLock(coordinator, vehicle) -def test_door_lock_state_available_gating(): - """door_lock_state_available mirrors the vehicle's remote-service flag.""" - coord = MagicMock() - assert _make(coord, _vehicle()).door_lock_state_available is True - assert ( - _make(coord, _vehicle(has_remote_services=False)).door_lock_state_available - is False - ) - - @pytest.mark.asyncio async def test_lock_invokes_remote_service_and_refreshes(): coord = MagicMock() diff --git a/tests/test_services.py b/tests/test_services.py index 6a0efea..d3ae6c1 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -22,15 +22,12 @@ def _vehicle(**zones) -> SimpleNamespace: - """A vehicle stand-in whose CLIMATIZER_STATE advertises the given zones. + """A vehicle stand-in whose HVAC_SUMMARY advertises the given zones. `zones` maps camelCase API keys (frontLeft/...) to their enabled flag; the KEYS are what the helper treats as "supported", regardless of value. """ - return SimpleNamespace( - vin="VIN1", - data={"CLIMATIZER_STATE": {"climateZonesEnabled": dict(zones)}}, - ) + return SimpleNamespace(vin="VIN1", seat_heating=dict(zones)) def test_requested_and_supported_zone_is_returned(): diff --git a/tests/test_switch.py b/tests/test_switch.py index dcc8210..6b2e0e7 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -111,3 +111,9 @@ async def test_climatise_turn_on_off_invoke_remote_service(): vehicle.remote_services.climatise_off.assert_awaited_once() assert coord.async_update_listeners.call_count == 2 + + +def test_climatise_switch_disabled_by_default(): + """The climate entity covers climatisation; the switch is opt-in.""" + assert _desc("climatise").entity_registry_enabled_default is False + assert _desc("direct_charging").entity_registry_enabled_default is True