From e27ace7e47af2e66d63a39f4c4065d8c35bf0815 Mon Sep 17 00:00:00 2001 From: Jeremie Date: Fri, 6 Nov 2026 09:04:12 +0100 Subject: [PATCH 1/2] Climatisation target from the car, data age sensor (0.6.0) Uses pyporscheconnectapi-bartolije 0.5.2: - Climate: starts from the target the car reports and follows it while climatisation runs (a target chosen while off is kept until then); remaining time as a remaining_time attribute. - New diagnostic sensor "Last update from the car" (GLOBAL_TIMESTAMP), translated into the seven languages: the other values are that old. - Tyre sensors keep working on cars that only report the per-corner TIRE_PRESSURE_* measurements (handled in the library). Co-Authored-By: Claude Opus 5.5 --- custom_components/porscheconnect/climate.py | 22 +++++++++++-- .../porscheconnect/manifest.json | 4 +-- custom_components/porscheconnect/sensor.py | 9 ++++++ custom_components/porscheconnect/strings.json | 3 ++ .../porscheconnect/translations/de.json | 3 ++ .../porscheconnect/translations/en.json | 3 ++ .../porscheconnect/translations/es.json | 3 ++ .../porscheconnect/translations/fr.json | 3 ++ .../porscheconnect/translations/it.json | 3 ++ .../porscheconnect/translations/nl.json | 3 ++ .../porscheconnect/translations/sv.json | 3 ++ requirements_test.txt | 2 +- tests/conftest.py | 3 ++ tests/test_climate.py | 28 +++++++++++++++++ tests/test_last_update_sensor.py | 31 +++++++++++++++++++ 15 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 tests/test_last_update_sensor.py diff --git a/custom_components/porscheconnect/climate.py b/custom_components/porscheconnect/climate.py index 29fcef6..d737bfc 100644 --- a/custom_components/porscheconnect/climate.py +++ b/custom_components/porscheconnect/climate.py @@ -85,9 +85,25 @@ def __init__( """Initialise the climate entity.""" super().__init__(coordinator, vehicle) self._attr_unique_id = f"{self._vin}-climate" - # The API reports on/off but not the previously-requested target, so we - # keep an optimistic target the user can adjust. - self._attr_native_target_temperature = _DEFAULT_TEMP_C + # Start from the target the car reports; the user's later choice is kept + # until climatisation runs, then the car's target is followed again. + reported = vehicle.climatisation_target_temperature + self._attr_native_target_temperature = ( + reported if reported is not None else _DEFAULT_TEMP_C + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Follow the target the car applies while climatisation runs.""" + reported = self.vehicle.climatisation_target_temperature + if self.vehicle.remote_climatise_on and reported is not None: + self._attr_native_target_temperature = reported + super()._handle_coordinator_update() + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the remaining climatisation time, in minutes, while it runs.""" + return {"remaining_time": self.vehicle.climatisation_remaining_time} @property def hvac_mode(self) -> HVACMode: diff --git a/custom_components/porscheconnect/manifest.json b/custom_components/porscheconnect/manifest.json index 2944a4a..67ed0fe 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.1"], - "version": "0.5.4" + "requirements": ["pyporscheconnectapi-bartolije==0.5.2"], + "version": "0.6.0" } diff --git a/custom_components/porscheconnect/sensor.py b/custom_components/porscheconnect/sensor.py index 6451513..03129b6 100644 --- a/custom_components/porscheconnect/sensor.py +++ b/custom_components/porscheconnect/sensor.py @@ -150,6 +150,15 @@ def _extract(v: PorscheVehicle) -> float | None: suggested_display_precision=0, is_available=lambda v: v.has_electric_drivetrain, ), + PorscheSensorEntityDescription( + # When the car last sent its data: the other values are that old. + key="last_update", + translation_key="last_update", + value_fn=lambda v: v.last_global_update, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + is_available=lambda v: "GLOBAL_TIMESTAMP" in v.data, + ), PorscheSensorEntityDescription( key="mileage", translation_key="mileage", diff --git a/custom_components/porscheconnect/strings.json b/custom_components/porscheconnect/strings.json index 0a73856..486e0a5 100644 --- a/custom_components/porscheconnect/strings.json +++ b/custom_components/porscheconnect/strings.json @@ -150,6 +150,9 @@ "state_of_charge": { "name": "State of charge" }, + "last_update": { + "name": "Last update from the car" + }, "mileage": { "name": "Mileage" }, diff --git a/custom_components/porscheconnect/translations/de.json b/custom_components/porscheconnect/translations/de.json index 77ec107..479bac1 100644 --- a/custom_components/porscheconnect/translations/de.json +++ b/custom_components/porscheconnect/translations/de.json @@ -150,6 +150,9 @@ "state_of_charge": { "name": "Ladezustand" }, + "last_update": { + "name": "Letzte Meldung des Fahrzeugs" + }, "mileage": { "name": "Kilometerstand" }, diff --git a/custom_components/porscheconnect/translations/en.json b/custom_components/porscheconnect/translations/en.json index 0a73856..486e0a5 100644 --- a/custom_components/porscheconnect/translations/en.json +++ b/custom_components/porscheconnect/translations/en.json @@ -150,6 +150,9 @@ "state_of_charge": { "name": "State of charge" }, + "last_update": { + "name": "Last update from the car" + }, "mileage": { "name": "Mileage" }, diff --git a/custom_components/porscheconnect/translations/es.json b/custom_components/porscheconnect/translations/es.json index 3088c72..53f94b1 100644 --- a/custom_components/porscheconnect/translations/es.json +++ b/custom_components/porscheconnect/translations/es.json @@ -150,6 +150,9 @@ "state_of_charge": { "name": "Estado de carga" }, + "last_update": { + "name": "Última actualización del vehículo" + }, "mileage": { "name": "Kilometraje" }, diff --git a/custom_components/porscheconnect/translations/fr.json b/custom_components/porscheconnect/translations/fr.json index f1a561a..c398cfe 100644 --- a/custom_components/porscheconnect/translations/fr.json +++ b/custom_components/porscheconnect/translations/fr.json @@ -150,6 +150,9 @@ "state_of_charge": { "name": "Niveau de charge" }, + "last_update": { + "name": "Dernière remontée de la voiture" + }, "mileage": { "name": "Kilométrage" }, diff --git a/custom_components/porscheconnect/translations/it.json b/custom_components/porscheconnect/translations/it.json index f6e9064..253f93b 100644 --- a/custom_components/porscheconnect/translations/it.json +++ b/custom_components/porscheconnect/translations/it.json @@ -150,6 +150,9 @@ "state_of_charge": { "name": "Stato di carica" }, + "last_update": { + "name": "Ultimo aggiornamento dal veicolo" + }, "mileage": { "name": "Chilometraggio" }, diff --git a/custom_components/porscheconnect/translations/nl.json b/custom_components/porscheconnect/translations/nl.json index 20a0751..6bb519f 100644 --- a/custom_components/porscheconnect/translations/nl.json +++ b/custom_components/porscheconnect/translations/nl.json @@ -150,6 +150,9 @@ "state_of_charge": { "name": "Laadtoestand" }, + "last_update": { + "name": "Laatste update van de auto" + }, "mileage": { "name": "Kilometerstand" }, diff --git a/custom_components/porscheconnect/translations/sv.json b/custom_components/porscheconnect/translations/sv.json index 82bed22..fc331ae 100644 --- a/custom_components/porscheconnect/translations/sv.json +++ b/custom_components/porscheconnect/translations/sv.json @@ -150,6 +150,9 @@ "state_of_charge": { "name": "Laddningsnivå" }, + "last_update": { + "name": "Senaste uppdatering från bilen" + }, "mileage": { "name": "Mätarställning" }, diff --git a/requirements_test.txt b/requirements_test.txt index ad34053..76024be 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pyporscheconnectapi-bartolije==0.5.1 +pyporscheconnectapi-bartolije==0.5.2 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 5d346a5..9c10404 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -49,6 +49,9 @@ def _make_vehicle() -> SimpleNamespace: vehicle.privacy_mode = False vehicle.picture_locations = {} vehicle.seat_heating = None + vehicle.climatisation_target_temperature = None + vehicle.climatisation_remaining_time = None + vehicle.last_global_update = None vehicle.location = (None, None, None) vehicle.location_updated_at = None vehicle.data = { diff --git a/tests/test_climate.py b/tests/test_climate.py index 9585b64..b34b052 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -31,6 +31,8 @@ def _vehicle(**overrides) -> SimpleNamespace: "has_remote_climatisation": True, "has_remote_services": True, "remote_climatise_on": False, + "climatisation_target_temperature": None, + "climatisation_remaining_time": None, } attrs.update(overrides) return SimpleNamespace(**attrs) @@ -126,3 +128,29 @@ async def test_api_error_maps_to_home_assistant_error(): with pytest.raises(HomeAssistantError): await climate.async_turn_on() + + +def test_target_starts_from_the_car(): + """The reported target is the starting point, 20 °C when none is reported.""" + coord = MagicMock() + assert _make(coord, _vehicle(climatisation_target_temperature=22.5)).native_target_temperature == 22.5 + assert _make(coord, _vehicle()).native_target_temperature == 20.0 + + +def test_target_follows_the_car_only_while_running(): + """A target chosen while off is kept; the car's target is followed while on.""" + vehicle = _vehicle(climatisation_target_temperature=19.0) + climate = _make(MagicMock(), vehicle) + climate.async_write_ha_state = MagicMock() + + climate._attr_native_target_temperature = 23.0 + climate._handle_coordinator_update() + assert climate.native_target_temperature == 23.0 + + vehicle.remote_climatise_on = True + vehicle.climatisation_target_temperature = 21.5 + vehicle.climatisation_remaining_time = 14 + climate._handle_coordinator_update() + assert climate.native_target_temperature == 21.5 + assert climate.extra_state_attributes == {"remaining_time": 14} + diff --git a/tests/test_last_update_sensor.py b/tests/test_last_update_sensor.py new file mode 100644 index 0000000..b4adc28 --- /dev/null +++ b/tests/test_last_update_sensor.py @@ -0,0 +1,31 @@ +"""Tests for the "last update from the car" sensor.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +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_last_update_sensor_reports_when_the_car_sent_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vehicle, + mock_connection_cls, + mock_account_cls, +) -> None: + """The sensor exists when the car reports GLOBAL_TIMESTAMP and shows it.""" + mock_vehicle.data["GLOBAL_TIMESTAMP"] = {"timestamp": "2026-11-06T06:30:00Z"} + mock_vehicle.last_global_update = datetime(2026, 11, 6, 6, 30, tzinfo=UTC) + 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() + + entity_id = er.async_get(hass).async_get_entity_id( + "sensor", DOMAIN, f"{mock_vehicle.vin}-last_update" + ) + assert hass.states.get(entity_id).state == "2026-11-06T06:30:00+00:00" From 0ec4dc0e850d2a79ab41581f61fa64300d87296c Mon Sep 17 00:00:00 2001 From: Jeremie Date: Fri, 6 Nov 2026 09:18:43 +0100 Subject: [PATCH 2/2] README: data age sensor and climate target from the car Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f74382e..085a14a 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ integration only creates entities that the car can actually drive. | `sensor` | `charging_power` | `has_electric_drivetrain` | | `sensor` | `charging_finished` | `has_electric_drivetrain` | | `sensor` | `remaining_range_electric` | `has_electric_drivetrain` | +| `sensor` | `last_update` (when the car last sent its data, diagnostic) | `GLOBAL_TIMESTAMP` reported | | `sensor` | `mileage` | `has_porsche_connect` (default) | | `sensor` | `remaining_range` | `has_ice_drivetrain` | | `sensor` | `fuel_level` | `has_ice_drivetrain` | @@ -113,7 +114,7 @@ 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) | -| `climate` | Remote climatisation (target temperature) | `has_remote_climatisation` and `has_remote_services` | +| `climate` | Remote climatisation (target read from the car, `remaining_time`) | `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` |