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
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
5 changes: 5 additions & 0 deletions pyporscheconnectapi/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"FUEL_LEVEL",
"FUEL_RESERVE",
"GLOBAL_PRIVACY_MODE",
"GLOBAL_TIMESTAMP",
"GPS_LOCATION",
"HEATING_STATE",
"HVAC_STATE",
Expand Down Expand Up @@ -104,6 +105,10 @@
"THEFT_STATE",
"TIMERS",
"TIRE_PRESSURE",
"TIRE_PRESSURE_FRONT_LEFT",
"TIRE_PRESSURE_FRONT_RIGHT",
"TIRE_PRESSURE_REAR_LEFT",
"TIRE_PRESSURE_REAR_RIGHT",
"VTS_MODES",
]

Expand Down
52 changes: 45 additions & 7 deletions pyporscheconnectapi/vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@

BASE_DATA = ["vin", "modelName", "modelType", "systemInfo", "timestamp"]

# Per-corner tyre measurements of the current API, mapped to the keys of the
# legacy TIRE_PRESSURE measurement that some cars still report.
_TIRE_CORNERS = {
"TIRE_PRESSURE_FRONT_LEFT": "frontLeftTire",
"TIRE_PRESSURE_FRONT_RIGHT": "frontRightTire",
"TIRE_PRESSURE_REAR_LEFT": "rearLeftTire",
"TIRE_PRESSURE_REAR_RIGHT": "rearRightTire",
}


def _parse_porsche_datetime(datetime_str: str) -> datetime.datetime:
"""Parse Porsche API timestamps, including UTC timestamps with a trailing Z."""
Expand Down Expand Up @@ -141,6 +150,23 @@ def remote_climatise_on(self) -> bool | None:
_LOGGER.debug("Remote climatisation status is: %s", status)
return status is not None and status != "OFF"

@property
def climatisation_target_temperature(self) -> float | None:
"""Return the climatisation target temperature in °C, if reported."""
kelvin = (self.data.get("HVAC_SUMMARY") or {}).get("targetTemperature")
return round(kelvin - 273.15, 1) if kelvin is not None else None

@property
def climatisation_remaining_time(self) -> int | None:
"""Return the remaining climatisation time in minutes while it runs."""
return (self.data.get("HVAC_SUMMARY") or {}).get("remainingTime")

@property
def last_global_update(self) -> datetime.datetime | None:
"""Return when the car last sent its data, if reported."""
timestamp = (self.data.get("GLOBAL_TIMESTAMP") or {}).get("timestamp")
return _parse_porsche_datetime(timestamp) if timestamp else None

@property
def seat_heating(self) -> dict | None:
"""Return the climate zones the vehicle reports, with their state."""
Expand Down Expand Up @@ -170,11 +196,11 @@ def doors_and_lids(self) -> dict:
@property
def tire_pressure_status(self) -> bool:
"""Return true if tire pressure is within the tolerances."""
tire_pressure_status = self.data.get("TIRE_PRESSURE") or {}
readings = self.tire_pressures or {}
differences = [
abs(tire_pressure_status[key]["differenceBar"])
for key in tire_pressure_status
if key.endswith("Tire")
abs(value["differenceBar"])
for key, value in readings.items()
if key.endswith("Tire") and isinstance(value, dict) and value.get("differenceBar") is not None
]
# No per-tire readings (or no TIRE_PRESSURE at all): nothing out of
# tolerance, so report OK rather than IndexError on an empty list.
Expand All @@ -184,13 +210,25 @@ def tire_pressure_status(self) -> bool:

@property
def tire_pressures(self) -> dict | None:
"""Return a dict containing tire pressure readings."""
return self.data.get("TIRE_PRESSURE")
"""Return the tyre readings keyed frontLeftTire, frontRightTire, ...

Taken from the legacy TIRE_PRESSURE measurement when it carries per-tyre
readings, else from the per-corner TIRE_PRESSURE_* measurements.
"""
legacy = self.data.get("TIRE_PRESSURE")
if isinstance(legacy, dict) and any(key.endswith("Tire") for key in legacy):
return legacy
corners = {
name: self.data[key]
for key, name in _TIRE_CORNERS.items()
if isinstance(self.data.get(key), dict)
}
return corners or legacy

@property
def has_tire_pressure_monitoring(self) -> bool:
"""Return True if vehicle has tire pressure monitoring."""
return self.data.get("TIRE_PRESSURE") is not None
return self.tire_pressures is not None

@property
def charging_target(self) -> int | None:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

setup(
name="pyporscheconnectapi-bartolije",
version="0.5.1",
version="0.5.2",
author="Johan Isaksson",
author_email="johan@generatorhallen.se",
description="Python library and CLI for communicating with Porsche Connect API.",
Expand Down
52 changes: 52 additions & 0 deletions tests/test_vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,55 @@ def test_drivetrain_capabilities_missing_modeltype(authed_connection: Connection
assert v.has_electric_drivetrain is False
assert v.has_ice_drivetrain is False
assert v.has_direct_charge is False


def test_tire_pressures_from_per_corner_measurements(authed_connection: Connection):
"""Cars that only report TIRE_PRESSURE_* still get readings, status and monitoring."""
v = PorscheVehicle(
connection=authed_connection,
data={
"TIRE_PRESSURE_FRONT_LEFT": {"differenceBar": -0.1, "actualPressureBar": 2.4},
"TIRE_PRESSURE_REAR_RIGHT": {"differenceBar": -0.6, "actualPressureBar": 2.0},
},
)
assert v.tire_pressures == {
"frontLeftTire": {"differenceBar": -0.1, "actualPressureBar": 2.4},
"rearRightTire": {"differenceBar": -0.6, "actualPressureBar": 2.0},
}
assert v.has_tire_pressure_monitoring is True
assert v.tire_pressure_status is False


def test_tire_pressures_prefer_legacy_readings(authed_connection: Connection):
"""The legacy measurement wins when it has per-tyre readings."""
legacy = {"frontLeftTire": {"differenceBar": 0.0}, "lastModified": "x"}
v = PorscheVehicle(
connection=authed_connection,
data={"TIRE_PRESSURE": legacy, "TIRE_PRESSURE_FRONT_LEFT": {"differenceBar": -0.5}},
)
assert v.tire_pressures == legacy
assert v.tire_pressure_status is True


def test_climatisation_target_and_remaining_time(authed_connection: Connection):
"""HVAC_SUMMARY reports the target in Kelvin and the remaining minutes."""
v = PorscheVehicle(
connection=authed_connection,
data={"HVAC_SUMMARY": {"status": "ON", "targetTemperature": 294.15, "remainingTime": 12}},
)
assert v.climatisation_target_temperature == 21.0
assert v.climatisation_remaining_time == 12
empty = PorscheVehicle(connection=authed_connection, data={})
assert empty.climatisation_target_temperature is None
assert empty.climatisation_remaining_time is None


def test_last_global_update(authed_connection: Connection):
"""GLOBAL_TIMESTAMP tells when the car last sent its data."""
v = PorscheVehicle(
connection=authed_connection,
data={"GLOBAL_TIMESTAMP": {"timestamp": "2026-11-06T06:30:00Z"}},
)
assert v.last_global_update.isoformat() == "2026-11-06T06:30:00+00:00"
assert PorscheVehicle(connection=authed_connection, data={}).last_global_update is None

Loading