From ddde7ba9e5579d257715a0b0cd079eee2032f470 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 08:46:28 -0500 Subject: [PATCH 01/30] Correct urls --- pyproject.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1e84a30..abe4347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,9 +28,10 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/austind/retryhttp" -Repository = "https://github.com/austind/retryhttp.git" -Issues = "https://github.com/austind/retryhttp/issues" +Homepage = "https://github.com/austind/vehiclepass" +Repository = "https://github.com/austind/vehiclepass.git" +Issues = "https://github.com/austind/vehiclepass/issues" +Changelog = "https://github.com/austind/vehiclepass/blob/main/CHANGELOG.md" [build-system] requires = ["hatchling"] From 5bdffea364a2d3d93d1ea403a5159bf034583fd0 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 08:46:36 -0500 Subject: [PATCH 02/30] Add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c0ace9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# v0.3.0 + +* Added `Pressure.bar` unit. From 2a4f172828971a4c6e7915d90bfc8d8873653e90 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 08:46:46 -0500 Subject: [PATCH 03/30] Add bar unit to Pressure --- src/vehiclepass/units.py | 5 +++++ uv.lock | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vehiclepass/units.py b/src/vehiclepass/units.py index 3df2490..2ce0967 100644 --- a/src/vehiclepass/units.py +++ b/src/vehiclepass/units.py @@ -105,6 +105,11 @@ class Pressure: kilopascals: float _decimal_places: int = field(default=DECIMAL_PLACES) + @property + def bar(self) -> float: + """Get pressure in bars.""" + return round(self.kilopascals / 100, self._decimal_places) + @property def kpa(self) -> float: """Get pressure in kilopascals.""" diff --git a/uv.lock b/uv.lock index 2229497..97d4bca 100644 --- a/uv.lock +++ b/uv.lock @@ -767,7 +767,7 @@ wheels = [ [[package]] name = "vehiclepass" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 3ff19b1da87f326356fe9c7c61873cac550eb7e1 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 08:59:08 -0500 Subject: [PATCH 04/30] Add contribution guide --- README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 96a1178..bddc9c0 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,35 @@ with vehiclepass.vehicle( print(v.shutoff_countdown) # 14m 58s print(v.shutoff_countdown.s) # 898.0 print(v.shutoff_time) # 2025-04-02 10:41:53.175933 -``` \ No newline at end of file +``` + +## Contributing + +Contributions welcome! Please [open an issue](https://github.com/austind/vehiclepass/issues/new) to discuss what you'd like to do first. + +### Development + +#### Prerequisites + +* [uv](https://docs.astral.sh/uv/getting-started/installation/) + +#### Environment + +1. [Fork this repo](https://github.com/austind/vehiclepass/fork) to your personal GitHub account +1. Clone the repo locally + ```sh + USERNAME= + git clone https://github.com/$USERNAME/vehiclepass.git + cd vehiclepass + ``` +1. Create a feature branch off of the `develop` branch + ```sh + git checkout develop + git checkout -b 1234-short-description # 1234 is the issue number you opened +1. Make your changes +1. Add at least one new test that covers your code, but as many as necessary +1. Ensure all tests pass + ```sh + nox -r # The -r flag re-uses virtualenvs for faster re-runs + ``` +1. [Open a pull request](https://github.com/austind/vehiclepass/compare) From b26bc0c9a399468f2d067842bab19b4400dba68c Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 09:02:09 -0500 Subject: [PATCH 05/30] Update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c0ace9..5112410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ # v0.3.0 -* Added `Pressure.bar` unit. +* Added `Pressure.bar` unit +* Fix URLs in pyproject.toml From 050cb82d74b056acb11d2b7bf46824ee07df6458 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 09:09:05 -0500 Subject: [PATCH 06/30] Fix doors logic --- src/vehiclepass/doors.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/vehiclepass/doors.py b/src/vehiclepass/doors.py index 6530d18..a47ef15 100644 --- a/src/vehiclepass/doors.py +++ b/src/vehiclepass/doors.py @@ -29,19 +29,20 @@ def __init__(self, vehicle: "Vehicle") -> None: for door in self._door_status: door_position = door.get("vehicleDoor", "").lower() - self._doors[door_position] = door["value"] - # Skip ALL_DOORS as it's handled separately - if door_position != "all_doors": - setattr(self, door_position, door["value"]) + if door_position not in ("all_doors", "unspecified_front"): + self._doors[door_position] = door["value"] # Handle unspecified_front door position # Observed on a 2021 Expedition. Other vehicles may have different values. if door_position == "unspecified_front": if door.get("vehicleSide", "").lower() == "driver": - self.front_left = door["value"] + self._doors["front_left"] = door["value"] elif door.get("vehicleSide", "").lower() == "passenger": - self.front_right = door["value"] + self._doors["front_right"] = door["value"] + + for door_position in self._doors: + setattr(self, door_position, self._doors[door_position]) @property def are_locked(self) -> bool: From 92fdfe725d24f6234e0eca4816d9d09b664c026f Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 09:09:19 -0500 Subject: [PATCH 07/30] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5112410..c65ec36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,3 +2,4 @@ * Added `Pressure.bar` unit * Fix URLs in pyproject.toml +* Fix door logic From 6e3e7b9dde7552517c2a9511933e34b0b598c876 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 09:09:32 -0500 Subject: [PATCH 08/30] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index abe4347..ce589b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vehiclepass" -version = "0.2.0" +version = "0.3.0" description = "A Python API to manage your FordPass-enabled vehicle." readme = "README.md" authors = [ From dfd4a517269fc0169793397a4706d5c736b45e8f Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 20:10:48 -0500 Subject: [PATCH 09/30] fix tests --- tests/test_doors.py | 1 - uv.lock | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_doors.py b/tests/test_doors.py index c9f1de9..f74fd59 100644 --- a/tests/test_doors.py +++ b/tests/test_doors.py @@ -12,7 +12,6 @@ def test_doors_closed(vehicle: vehiclepass.Vehicle) -> None: assert isinstance(vehicle.doors, Doors) assert vehicle.doors.are_unlocked assert vehicle.doors.are_locked is False - assert hasattr(vehicle.doors, "unspecified_front") assert hasattr(vehicle.doors, "front_left") assert hasattr(vehicle.doors, "front_right") assert hasattr(vehicle.doors, "rear_left") diff --git a/uv.lock b/uv.lock index 97d4bca..a708612 100644 --- a/uv.lock +++ b/uv.lock @@ -767,7 +767,7 @@ wheels = [ [[package]] name = "vehiclepass" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From f8ad5a0629c1e8c010cb02c6028b45bdffb0e677 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 21:37:31 -0500 Subject: [PATCH 10/30] tests and docstrings --- src/vehiclepass/tire_pressure.py | 4 +-- src/vehiclepass/vehicle.py | 4 +-- tests/test_doors.py | 20 ++++++-------- tests/test_status.py | 46 +++++++++++++++++++++++--------- 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/vehiclepass/tire_pressure.py b/src/vehiclepass/tire_pressure.py index e9c006f..663a88a 100644 --- a/src/vehiclepass/tire_pressure.py +++ b/src/vehiclepass/tire_pressure.py @@ -1,4 +1,4 @@ -"""Tire pressure reading for all vehicle wheels.""" +"""Tire pressure reading for all vehicle tires.""" from typing import TYPE_CHECKING @@ -9,7 +9,7 @@ class TirePressure: - """Represents tire pressure readings for all vehicle wheels.""" + """Represents tire pressure readings for all vehicle tires.""" def __init__(self, vehicle: "Vehicle") -> None: """Initialize tire pressure readings from status data and dynamically create properties. diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index e84499b..b596e3a 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -106,7 +106,7 @@ def _get_metric_value(self, metric_name: str, expected_type: Optional[type[T]] = expected_type: The expected type of the value (optional) Returns: - The metric value, rounded to 2 decimal places if numeric + The metric value Raises: StatusError: If the metric is not found or invalid @@ -124,7 +124,7 @@ def _get_metric_value(self, metric_name: str, expected_type: Optional[type[T]] = value = metric if expected_type is not None and not isinstance(value, expected_type): - raise StatusError(f"Invalid {metric_name} type") + raise StatusError(f"Invalid {metric_name} type: expected {expected_type}, got {type(value)}") return value # type: ignore except Exception as exc: if isinstance(exc, StatusError): diff --git a/tests/test_doors.py b/tests/test_doors.py index f74fd59..7c00ca6 100644 --- a/tests/test_doors.py +++ b/tests/test_doors.py @@ -12,18 +12,14 @@ def test_doors_closed(vehicle: vehiclepass.Vehicle) -> None: assert isinstance(vehicle.doors, Doors) assert vehicle.doors.are_unlocked assert vehicle.doors.are_locked is False - assert hasattr(vehicle.doors, "front_left") - assert hasattr(vehicle.doors, "front_right") - assert hasattr(vehicle.doors, "rear_left") - assert hasattr(vehicle.doors, "rear_right") - assert hasattr(vehicle.doors, "tailgate") - assert hasattr(vehicle.doors, "inner_tailgate") - assert vehicle.doors.front_left == "CLOSED" - assert vehicle.doors.front_right == "CLOSED" - assert vehicle.doors.rear_left == "CLOSED" # type: ignore - assert vehicle.doors.rear_right == "CLOSED" # type: ignore - assert vehicle.doors.tailgate == "CLOSED" # type: ignore - assert vehicle.doors.inner_tailgate == "CLOSED" # type: ignore + # We call these with getattr to avoid mypy errors, as doors are dynamically + # created in the Doors class. + assert getattr(vehicle.doors, "front_left") == "CLOSED" # noqa: B009 + assert getattr(vehicle.doors, "front_right") == "CLOSED" # noqa: B009 + assert getattr(vehicle.doors, "rear_left") == "CLOSED" # noqa: B009 + assert getattr(vehicle.doors, "rear_right") == "CLOSED" # noqa: B009 + assert getattr(vehicle.doors, "tailgate") == "CLOSED" # noqa: B009 + assert getattr(vehicle.doors, "inner_tailgate") == "CLOSED" # noqa: B009 @mock_responses( diff --git a/tests/test_status.py b/tests/test_status.py index c23cbb4..7a0cd4c 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -11,22 +11,44 @@ from .conftest import mock_responses +@mock_responses(status="status/baseline.json") +def test_get_metric_value(vehicle: vehiclepass.Vehicle) -> None: + """Test that _get_metric_value returns the correct value.""" + assert isinstance(vehicle._get_metric_value("batteryVoltage"), float) + assert isinstance(vehicle._get_metric_value("compassDirection"), str) + with pytest.raises(vehiclepass.StatusError): + vehicle._get_metric_value("batteryVoltage", str) # Incorrect type + vehicle._get_metric_value("invalid_metric") + + @mock_responses(status="status/baseline.json") def test_tire_pressure(vehicle: vehiclepass.Vehicle) -> None: """Test tire pressure properties.""" assert isinstance(vehicle.tire_pressure, TirePressure) - assert vehicle.tire_pressure.front_left.psi == 39.45 # type: ignore - assert str(vehicle.tire_pressure.front_left) == "39.45 psi" # type: ignore - assert vehicle.tire_pressure.front_left.kpa == 272.0 # type: ignore - assert vehicle.tire_pressure.front_right.psi == 40.18 # type: ignore - assert str(vehicle.tire_pressure.front_right) == "40.18 psi" # type: ignore - assert vehicle.tire_pressure.front_right.kpa == 277.0 # type: ignore - assert vehicle.tire_pressure.rear_left.psi == 39.89 # type: ignore - assert str(vehicle.tire_pressure.rear_left) == "39.89 psi" # type: ignore - assert vehicle.tire_pressure.rear_left.kpa == 275.0 # type: ignore - assert vehicle.tire_pressure.rear_right.psi == 39.89 # type: ignore - assert str(vehicle.tire_pressure.rear_right) == "39.89 psi" # type: ignore - assert vehicle.tire_pressure.rear_right.kpa == 275.0 # type: ignore + + # Front left + assert getattr(vehicle.tire_pressure, "front_left").psi == 39.45 # noqa: B009 + assert getattr(vehicle.tire_pressure, "front_left").bar == 2.72 # noqa: B009 + assert getattr(vehicle.tire_pressure, "front_left").kpa == 272.0 # noqa: B009 + assert str(getattr(vehicle.tire_pressure, "front_left")) == "39.45 psi" # noqa: B009 + + # Front right + assert getattr(vehicle.tire_pressure, "front_right").psi == 40.18 # noqa: B009 + assert getattr(vehicle.tire_pressure, "front_right").kpa == 277.0 # noqa: B009 + assert getattr(vehicle.tire_pressure, "front_right").bar == 2.77 # noqa: B009 + assert str(getattr(vehicle.tire_pressure, "front_right")) == "40.18 psi" # noqa: B009 + + # Rear left + assert getattr(vehicle.tire_pressure, "rear_left").psi == 39.89 # noqa: B009 + assert getattr(vehicle.tire_pressure, "rear_left").bar == 2.75 # noqa: B009 + assert getattr(vehicle.tire_pressure, "rear_left").kpa == 275.0 # noqa: B009 + assert str(getattr(vehicle.tire_pressure, "rear_left")) == "39.89 psi" # noqa: B009 + + # Rear right + assert getattr(vehicle.tire_pressure, "rear_right").psi == 39.89 # noqa: B009 + assert getattr(vehicle.tire_pressure, "rear_right").bar == 2.75 # noqa: B009 + assert getattr(vehicle.tire_pressure, "rear_right").kpa == 275.0 # noqa: B009 + assert str(getattr(vehicle.tire_pressure, "rear_right")) == "39.89 psi" # noqa: B009 @mock_responses(status="status/baseline.json") From 12cf3c51ed8b3d39541d4c22703b0f0cd8196735 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 6 Apr 2025 21:46:41 -0500 Subject: [PATCH 11/30] add tpms status --- src/vehiclepass/tire_pressure.py | 9 +++++++++ tests/test_status.py | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/vehiclepass/tire_pressure.py b/src/vehiclepass/tire_pressure.py index 663a88a..104c11c 100644 --- a/src/vehiclepass/tire_pressure.py +++ b/src/vehiclepass/tire_pressure.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from vehiclepass.errors import StatusError from vehiclepass.units import Pressure if TYPE_CHECKING: @@ -24,6 +25,14 @@ def __init__(self, vehicle: "Vehicle") -> None: self._tires[tire_position] = Pressure.from_kilopascals(tire["value"]) setattr(self, tire_position, self._tires[tire_position]) + @property + def system_status(self) -> str: + """Get the system status of the tire pressure system.""" + try: + return self._vehicle._get_metric_value("tirePressureSystemStatus", list)[0]["value"] + except (IndexError, KeyError) as exc: + raise StatusError("Tire pressure system status not found") from exc + def __repr__(self): """Return string representation showing available tire positions.""" positions = list(self._tires.keys()) diff --git a/tests/test_status.py b/tests/test_status.py index 7a0cd4c..a3b4e44 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -22,7 +22,7 @@ def test_get_metric_value(vehicle: vehiclepass.Vehicle) -> None: @mock_responses(status="status/baseline.json") -def test_tire_pressure(vehicle: vehiclepass.Vehicle) -> None: +def test_tire_pressure_values(vehicle: vehiclepass.Vehicle) -> None: """Test tire pressure properties.""" assert isinstance(vehicle.tire_pressure, TirePressure) @@ -51,6 +51,12 @@ def test_tire_pressure(vehicle: vehiclepass.Vehicle) -> None: assert str(getattr(vehicle.tire_pressure, "rear_right")) == "39.89 psi" # noqa: B009 +@mock_responses(status="status/baseline.json") +def test_tire_pressure_system_status(vehicle: vehiclepass.Vehicle) -> None: + """Test tire pressure system status.""" + assert vehicle.tire_pressure.system_status == "NORMAL_OPERATION" + + @mock_responses(status="status/baseline.json") def test_temperature(vehicle: vehiclepass.Vehicle) -> None: """Test temperature properties.""" From 310f3dc194834c85c624dafa05bd8d2fa52dfbe0 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 06:58:52 -0500 Subject: [PATCH 12/30] Work on tires --- CHANGELOG.md | 6 ++ src/vehiclepass/constants.py | 3 + src/vehiclepass/tire_pressure.py | 39 ------------ src/vehiclepass/tires.py | 73 ++++++++++++++++++++++ src/vehiclepass/vehicle.py | 10 ++- tests/conftest.py | 13 ++-- tests/fixtures/responses/unauthorized.json | 7 +++ tests/test_status.py | 52 ++++++++------- 8 files changed, 133 insertions(+), 70 deletions(-) delete mode 100644 src/vehiclepass/tire_pressure.py create mode 100644 src/vehiclepass/tires.py create mode 100644 tests/fixtures/responses/unauthorized.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c65ec36..4ff83b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v0.4.0 + +* Add several tests +* Add TPMS status (`vehicle.tire_pressure.system_status`) +* + # v0.3.0 * Added `Pressure.bar` unit diff --git a/src/vehiclepass/constants.py b/src/vehiclepass/constants.py index 26a9d0a..6335661 100644 --- a/src/vehiclepass/constants.py +++ b/src/vehiclepass/constants.py @@ -1,6 +1,7 @@ """Constants.""" import os +from pathlib import Path from dotenv import load_dotenv @@ -51,3 +52,5 @@ f"VEHICLEPASS_DEFAULT_TIME_UNIT: Invalid unit: {DEFAULT_TIME_UNIT}. " "Valid units are: s, m, h, ms, human_readable" ) + +MOCK_RESPONSES_DIR = Path(__file__).parent.parent.parent / "tests" / "fixtures" / "responses" diff --git a/src/vehiclepass/tire_pressure.py b/src/vehiclepass/tire_pressure.py deleted file mode 100644 index 104c11c..0000000 --- a/src/vehiclepass/tire_pressure.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Tire pressure reading for all vehicle tires.""" - -from typing import TYPE_CHECKING - -from vehiclepass.errors import StatusError -from vehiclepass.units import Pressure - -if TYPE_CHECKING: - from vehiclepass.vehicle import Vehicle - - -class TirePressure: - """Represents tire pressure readings for all vehicle tires.""" - - def __init__(self, vehicle: "Vehicle") -> None: - """Initialize tire pressure readings from status data and dynamically create properties. - - Args: - vehicle: The parent Vehicle object - """ - self._vehicle = vehicle - self._tires = {} - for tire in self._vehicle._get_metric_value("tirePressure", list): - tire_position = tire["vehicleWheel"].lower() - self._tires[tire_position] = Pressure.from_kilopascals(tire["value"]) - setattr(self, tire_position, self._tires[tire_position]) - - @property - def system_status(self) -> str: - """Get the system status of the tire pressure system.""" - try: - return self._vehicle._get_metric_value("tirePressureSystemStatus", list)[0]["value"] - except (IndexError, KeyError) as exc: - raise StatusError("Tire pressure system status not found") from exc - - def __repr__(self): - """Return string representation showing available tire positions.""" - positions = list(self._tires.keys()) - return f"TirePressure(positions={positions})" diff --git a/src/vehiclepass/tires.py b/src/vehiclepass/tires.py new file mode 100644 index 0000000..cb45cab --- /dev/null +++ b/src/vehiclepass/tires.py @@ -0,0 +1,73 @@ +"""Tire pressure reading for all vehicle tires.""" + +import logging +from typing import TYPE_CHECKING + +from vehiclepass.errors import StatusError +from vehiclepass.units import Pressure + +if TYPE_CHECKING: + from vehiclepass.vehicle import Vehicle + +logger = logging.getLogger(__name__) + + +class Tire: + """Represents tire status for a single tire.""" + + def __init__(self, vehicle: "Vehicle", tire_position: str, pressure: Pressure, status: str) -> None: + """Initialize tire status for a single tire.""" + self._vehicle = vehicle + self._tire_position = tire_position + self.pressure = pressure + self.pressure_status = status + + def __repr__(self): + """Return string representation showing tire position and pressure.""" + return f"Tire(position={self._tire_position}, pressure={self.pressure})" + + +class Tires: + """Represents tire status for all vehicle tires.""" + + def __init__(self, vehicle: "Vehicle") -> None: + """Initialize tire status from status data and dynamically create properties. + + Args: + vehicle: The parent Vehicle object + """ + self._vehicle = vehicle + self._tires = {} + for tire in self._vehicle._get_metric_value("tirePressure", list): + tire_position = tire["vehicleWheel"].lower() + logger.debug("Tire position: %s", tire_position) + self._tires[tire_position] = {"pressure": tire["value"]} + + for tire in self._vehicle._get_metric_value("tirePressureStatus", list): + tire_position = tire["vehicleWheel"].lower() + self._tires[tire_position].update({"status": tire["value"]}) + + for tire_position, data in self._tires.items(): + setattr( + self, + tire_position, + Tire( + self._vehicle, + tire_position, + pressure=Pressure.from_kilopascals(data["pressure"]), + status=data["status"], + ), + ) + + @property + def system_status(self) -> str: + """Get the system status of the tire pressure monitoringsystem.""" + try: + return self._vehicle._get_metric_value("tirePressureSystemStatus", list)[0]["value"] + except (IndexError, KeyError) as exc: + raise StatusError("Tire pressure monitoring system status not found") from exc + + def __repr__(self): + """Return string representation showing available tire positions.""" + positions, values = zip(*self._tires.items()) + return f"Tires(positions={positions}, values={values})" diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index b596e3a..6059a3a 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -25,7 +25,7 @@ from vehiclepass.doors import Doors from vehiclepass.errors import CommandError, StatusError from vehiclepass.indicators import Indicators -from vehiclepass.tire_pressure import TirePressure +from vehiclepass.tires import Tires from vehiclepass.units import Distance, Duration, ElectricPotential, Percentage, Temperature load_dotenv() @@ -64,6 +64,9 @@ def __init__( self._autonomic_token = None self._remote_start_count = 0 + self.tires = Tires(self) + self.tyres = self.tires + def __enter__(self) -> "Vehicle": """Enter the context manager.""" self.auth() @@ -522,8 +525,3 @@ def status(self) -> dict: if not self._status: self.refresh_status() return self._status - - @property - def tire_pressure(self) -> TirePressure: - """Get the tire pressure readings.""" - return TirePressure(self) diff --git a/tests/conftest.py b/tests/conftest.py index 7b0d2ad..97dc823 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,10 +4,10 @@ import json import logging import re -from collections.abc import Callable, Iterable +from collections.abc import Iterable from itertools import chain, repeat from pathlib import Path -from typing import Any, TypeVar, Union +from typing import Any, Callable, TypeVar, Union import httpx import pytest @@ -18,11 +18,12 @@ AUTONOMIC_COMMAND_BASE_URL, AUTONOMIC_TELEMETRY_BASE_URL, FORDPASS_AUTH_URL, + MOCK_RESPONSES_DIR, ) -T = TypeVar("T") +logger = logging.getLogger(__name__) -MOCK_RESPONSES_DIR = Path(__file__).parent / "fixtures" / "responses" +T = TypeVar("T") def pytest_configure(config): @@ -44,11 +45,15 @@ def load_mock_json(file_path: Union[str, Path]) -> dict[str, Any]: # Check if it's a relative path and try to find it in mock_data directory if not original_path.is_absolute(): + logger.debug("Original path: %s", original_path) if original_path.exists(): + logger.debug("Original path exists: %s", original_path) final_path = original_path else: relative_path = MOCK_RESPONSES_DIR / original_path + logger.debug("Relative path: %s", relative_path) if relative_path.exists(): + logger.debug("Relative path exists: %s", relative_path) final_path = relative_path else: raise FileNotFoundError(f"Mock data file not found: {original_path}") diff --git a/tests/fixtures/responses/unauthorized.json b/tests/fixtures/responses/unauthorized.json new file mode 100644 index 0000000..d0bd891 --- /dev/null +++ b/tests/fixtures/responses/unauthorized.json @@ -0,0 +1,7 @@ +{ + "code": 401, + "error": "unauthorized", + "message": "request did not provide a token", + "timestamp": "2025-04-07T11:38:40.735290Z", + "referenceId": "e7f0a5d9-919a-47b6-8509-f2497845f236" +} \ No newline at end of file diff --git a/tests/test_status.py b/tests/test_status.py index a3b4e44..46c2213 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -6,7 +6,7 @@ import vehiclepass from vehiclepass import units from vehiclepass.constants import AUTONOMIC_AUTH_URL, AUTONOMIC_TELEMETRY_BASE_URL -from vehiclepass.tire_pressure import TirePressure +from vehiclepass.tires import Tires from .conftest import mock_responses @@ -22,39 +22,49 @@ def test_get_metric_value(vehicle: vehiclepass.Vehicle) -> None: @mock_responses(status="status/baseline.json") -def test_tire_pressure_values(vehicle: vehiclepass.Vehicle) -> None: +def test_tire_pressure(vehicle: vehiclepass.Vehicle) -> None: """Test tire pressure properties.""" - assert isinstance(vehicle.tire_pressure, TirePressure) + assert isinstance(vehicle.tires, Tires) # Front left - assert getattr(vehicle.tire_pressure, "front_left").psi == 39.45 # noqa: B009 - assert getattr(vehicle.tire_pressure, "front_left").bar == 2.72 # noqa: B009 - assert getattr(vehicle.tire_pressure, "front_left").kpa == 272.0 # noqa: B009 - assert str(getattr(vehicle.tire_pressure, "front_left")) == "39.45 psi" # noqa: B009 + assert getattr(vehicle.tires, "front_left").pressure.psi == 39.45 # noqa: B009 + assert getattr(vehicle.tires, "front_left").pressure.bar == 2.72 # noqa: B009 + assert getattr(vehicle.tires, "front_left").pressure.kpa == 272.0 # noqa: B009 + assert str(getattr(vehicle.tires, "front_left").pressure) == "39.45 psi" # noqa: B009 # Front right - assert getattr(vehicle.tire_pressure, "front_right").psi == 40.18 # noqa: B009 - assert getattr(vehicle.tire_pressure, "front_right").kpa == 277.0 # noqa: B009 - assert getattr(vehicle.tire_pressure, "front_right").bar == 2.77 # noqa: B009 - assert str(getattr(vehicle.tire_pressure, "front_right")) == "40.18 psi" # noqa: B009 + assert getattr(vehicle.tires, "front_right").pressure.psi == 40.18 # noqa: B009 + assert getattr(vehicle.tires, "front_right").pressure.kpa == 277.0 # noqa: B009 + assert getattr(vehicle.tires, "front_right").pressure.bar == 2.77 # noqa: B009 + assert str(getattr(vehicle.tires, "front_right").pressure) == "40.18 psi" # noqa: B009 # Rear left - assert getattr(vehicle.tire_pressure, "rear_left").psi == 39.89 # noqa: B009 - assert getattr(vehicle.tire_pressure, "rear_left").bar == 2.75 # noqa: B009 - assert getattr(vehicle.tire_pressure, "rear_left").kpa == 275.0 # noqa: B009 - assert str(getattr(vehicle.tire_pressure, "rear_left")) == "39.89 psi" # noqa: B009 + assert getattr(vehicle.tires, "rear_left").pressure.psi == 39.89 # noqa: B009 + assert getattr(vehicle.tires, "rear_left").pressure.bar == 2.75 # noqa: B009 + assert getattr(vehicle.tires, "rear_left").pressure.kpa == 275.0 # noqa: B009 + assert str(getattr(vehicle.tires, "rear_left").pressure) == "39.89 psi" # noqa: B009 # Rear right - assert getattr(vehicle.tire_pressure, "rear_right").psi == 39.89 # noqa: B009 - assert getattr(vehicle.tire_pressure, "rear_right").bar == 2.75 # noqa: B009 - assert getattr(vehicle.tire_pressure, "rear_right").kpa == 275.0 # noqa: B009 - assert str(getattr(vehicle.tire_pressure, "rear_right")) == "39.89 psi" # noqa: B009 + assert getattr(vehicle.tires, "rear_right").pressure.psi == 39.89 # noqa: B009 + assert getattr(vehicle.tires, "rear_right").pressure.bar == 2.75 # noqa: B009 + assert getattr(vehicle.tires, "rear_right").pressure.kpa == 275.0 # noqa: B009 + assert str(getattr(vehicle.tires, "rear_right").pressure) == "39.89 psi" # noqa: B009 @mock_responses(status="status/baseline.json") -def test_tire_pressure_system_status(vehicle: vehiclepass.Vehicle) -> None: +def test_tire_status(vehicle: vehiclepass.Vehicle) -> None: + """Test tire status properties.""" + assert isinstance(vehicle.tires, Tires) + assert vehicle.tires.front_left.status == "NORMAL" + assert vehicle.tires.front_right.status == "NORMAL" + assert vehicle.tires.rear_left.status == "NORMAL" + assert vehicle.tires.rear_right.status == "NORMAL" + + +@mock_responses(status="status/baseline.json") +def test_tires_system_status(vehicle: vehiclepass.Vehicle) -> None: """Test tire pressure system status.""" - assert vehicle.tire_pressure.system_status == "NORMAL_OPERATION" + assert vehicle.tires.system_status == "NORMAL_OPERATION" @mock_responses(status="status/baseline.json") From b4b657e487a671e88b5c638c700ce29b7cf33072 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 08:24:35 -0500 Subject: [PATCH 13/30] fix regression --- src/vehiclepass/vehicle.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index 6059a3a..7c7caeb 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -64,9 +64,6 @@ def __init__( self._autonomic_token = None self._remote_start_count = 0 - self.tires = Tires(self) - self.tyres = self.tires - def __enter__(self) -> "Vehicle": """Enter the context manager.""" self.auth() @@ -525,3 +522,13 @@ def status(self) -> dict: if not self._status: self.refresh_status() return self._status + + @property + def tires(self) -> Tires: + """Get tires.""" + return Tires(self) + + @property + def tyres(self) -> Tires: + """Get tyres.""" + return self.tires From 694136b04bd5955822e5ec83209a77a345f85f62 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 08:27:16 -0500 Subject: [PATCH 14/30] Move mock_responses to tests.utils --- tests/conftest.py | 169 +------------------------------------ tests/test_commands.py | 3 +- tests/test_doors.py | 3 +- tests/test_fixtures.py | 3 +- tests/test_start_stop.py | 3 +- tests/test_status.py | 3 +- tests/utils.py | 174 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 182 insertions(+), 176 deletions(-) create mode 100644 tests/utils.py diff --git a/tests/conftest.py b/tests/conftest.py index 97dc823..7535fec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,185 +1,22 @@ """Fixtures for testing the vehiclepass library using respx.""" -import functools -import json import logging -import re -from collections.abc import Iterable -from itertools import chain, repeat from pathlib import Path -from typing import Any, Callable, TypeVar, Union +from typing import TypeVar -import httpx import pytest import respx -from vehiclepass.constants import ( - AUTONOMIC_AUTH_URL, - AUTONOMIC_COMMAND_BASE_URL, - AUTONOMIC_TELEMETRY_BASE_URL, - FORDPASS_AUTH_URL, - MOCK_RESPONSES_DIR, -) - -logger = logging.getLogger(__name__) - T = TypeVar("T") +MOCK_RESPONSES_DIR = Path(__file__).parent / "fixtures" / "responses" + def pytest_configure(config): """Configure pytest.""" logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)") -def load_mock_json(file_path: Union[str, Path]) -> dict[str, Any]: - """Load mock data from a JSON file. - - Args: - file_path: Path to the JSON file - - Returns: - Dict containing the loaded JSON data - """ - original_path = Path(file_path) - final_path = original_path - - # Check if it's a relative path and try to find it in mock_data directory - if not original_path.is_absolute(): - logger.debug("Original path: %s", original_path) - if original_path.exists(): - logger.debug("Original path exists: %s", original_path) - final_path = original_path - else: - relative_path = MOCK_RESPONSES_DIR / original_path - logger.debug("Relative path: %s", relative_path) - if relative_path.exists(): - logger.debug("Relative path exists: %s", relative_path) - final_path = relative_path - else: - raise FileNotFoundError(f"Mock data file not found: {original_path}") - - with open(final_path) as f: - return json.load(f) - - -def mock_responses( - status: Union[httpx.Response, str, Path, list[Union[str, Path, httpx.Response]], dict[str, Any], None] = None, - commands: Union[ - dict[str, Union[httpx.Response, str, Path, list[Union[str, Path, httpx.Response]], dict[str, Any]]], None - ] = None, - auth_token: str = "mock-token-12345", -): - """Test decorator that mocks HTTP API responses. - - Intercepts network requests for the FordPass API and returns predefined mock - responses instead of making actual API calls. - - Args: - status: Controls responses for status/telemetry endpoint requests. - - httpx.Response: Used directly as the response - - str or Path: Path to a JSON file, loaded and returned with 200 status code - - dict: Raw JSON data, returned with 200 status code - - list: Multiple responses returned in sequence for consecutive calls - (last item repeats for any additional calls) - - None: Returns a default baseline status from "status/baseline.json" - - commands: Controls responses for vehicle command endpoint requests. - - dict mapping command names to response definitions - - Each response can be a str/Path to JSON file, raw dict, or a list of - responses returned in sequence (like status) - - None: All command requests return 404 - - auth_token: The mock authentication token to use for all authorized requests. - - Returns: - A decorated test function that runs within the mocked HTTP environment. - - Example: - @mock_responses( - status="tests/mocks/vehicle_status.json", - commands={ - "unlock": "tests/mocks/unlock_success.json", - "lock": [ - "tests/mocks/command_accepted.json", - "tests/mocks/lock_complete.json" - ] - } - ) - def test_vehicle_commands(self): - # Test code that makes API calls, which will now use mock responses - ... - """ - - def decorator(test_func: Callable[..., T]) -> Callable[..., T]: - def get_response_data(source): - if isinstance(source, (str, Path)): - return load_mock_json(source) - return source - - command_indexes = {} - - def get_response(source_data, index_tracker): - # For non-list sources, just return the data directly - if not isinstance(source_data, list): - return get_response_data(source_data) - - # For lists, return the current item or the last one if we've reached the end - current = index_tracker["current"] - if current < len(source_data) - 1: - index_tracker["current"] = current + 1 - return get_response_data(source_data[current]) - # Return the last item for all subsequent calls - return get_response_data(source_data[-1]) - - def status_handler() -> Union[Iterable[httpx.Response], httpx.Response]: - if isinstance(status, (str, Path)): - return httpx.Response(status_code=200, json=load_mock_json(status)) - if isinstance(status, list): - responses = [ - s if isinstance(s, httpx.Response) else httpx.Response(status_code=200, json=load_mock_json(s)) - for s in status - ] - return chain(responses, repeat(responses[-1])) - if isinstance(status, dict): - return httpx.Response(status_code=200, json=status) - return httpx.Response(status_code=200, json=load_mock_json("status/baseline.json")) - - def command_handler(request): - if commands is None: - return httpx.Response(status_code=404) - - command = json.loads(request.content.decode("utf-8")).get("type") - if command not in commands: - return httpx.Response(status_code=404) - - if command not in command_indexes: - command_indexes[command] = {"current": 0} - - response_data = get_response(commands[command], command_indexes[command]) - return ( - response_data - if isinstance(response_data, httpx.Response) - else httpx.Response(status_code=200, json=response_data) - ) - - @functools.wraps(test_func) - def wrapper(*args, **kwargs): - with respx.mock(assert_all_called=False) as mock: - mock.post(FORDPASS_AUTH_URL).respond( - json={"access_token": auth_token, "token_type": "Bearer", "expires_in": 3600} - ) - mock.post(AUTONOMIC_AUTH_URL).respond( - json={"access_token": auth_token, "token_type": "Bearer", "expires_in": 3600} - ) - mock.get(re.compile(rf"{AUTONOMIC_TELEMETRY_BASE_URL}/*")).side_effect = status_handler() # type: ignore - mock.post(re.compile(rf"{AUTONOMIC_COMMAND_BASE_URL}/*")).side_effect = command_handler - return test_func(*args, **kwargs) - - return wrapper - - return decorator - - @pytest.fixture def vehicle(): """Fixture for a basic Vehicle instance.""" diff --git a/tests/test_commands.py b/tests/test_commands.py index 952cd09..b9840ad 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3,8 +3,7 @@ import pytest import vehiclepass - -from .conftest import mock_responses +from tests.utils import mock_responses @mock_responses( diff --git a/tests/test_doors.py b/tests/test_doors.py index 7c00ca6..fc81bc4 100644 --- a/tests/test_doors.py +++ b/tests/test_doors.py @@ -1,10 +1,9 @@ """Test vehicle doors.""" import vehiclepass +from tests.utils import mock_responses from vehiclepass.doors import Doors -from .conftest import mock_responses - @mock_responses(status="status/unlocked.json") def test_doors_closed(vehicle: vehiclepass.Vehicle) -> None: diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 7db21ac..531590c 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -1,8 +1,7 @@ """Test fixtures.""" import vehiclepass - -from .conftest import mock_responses +from tests.utils import mock_responses @mock_responses( diff --git a/tests/test_start_stop.py b/tests/test_start_stop.py index f4c0659..ba58d89 100644 --- a/tests/test_start_stop.py +++ b/tests/test_start_stop.py @@ -4,8 +4,7 @@ import pytest import vehiclepass - -from .conftest import load_mock_json, mock_responses +from tests.utils import load_mock_json, mock_responses @mock_responses( diff --git a/tests/test_status.py b/tests/test_status.py index 46c2213..797ac16 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -4,12 +4,11 @@ from respx import MockRouter import vehiclepass +from tests.utils import mock_responses from vehiclepass import units from vehiclepass.constants import AUTONOMIC_AUTH_URL, AUTONOMIC_TELEMETRY_BASE_URL from vehiclepass.tires import Tires -from .conftest import mock_responses - @mock_responses(status="status/baseline.json") def test_get_metric_value(vehicle: vehiclepass.Vehicle) -> None: diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..cbd8549 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,174 @@ +"""Test utilities.""" + +import functools +import json +import logging +import re +from collections.abc import Callable, Iterable +from itertools import chain, repeat +from pathlib import Path +from typing import Any, TypeVar, Union + +import httpx +import respx + +from vehiclepass.constants import ( + AUTONOMIC_AUTH_URL, + AUTONOMIC_COMMAND_BASE_URL, + AUTONOMIC_TELEMETRY_BASE_URL, + FORDPASS_AUTH_URL, +) + +T = TypeVar("T") + +MOCK_RESPONSES_DIR = Path(__file__).parent / "fixtures" / "responses" + + +def pytest_configure(config): + """Configure pytest.""" + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)") + + +def load_mock_json(file_path: Union[str, Path]) -> dict[str, Any]: + """Load mock data from a JSON file. + + Args: + file_path: Path to the JSON file + + Returns: + Dict containing the loaded JSON data + """ + original_path = Path(file_path) + final_path = original_path + + # Check if it's a relative path and try to find it in mock_data directory + if not original_path.is_absolute(): + if original_path.exists(): + final_path = original_path + else: + relative_path = MOCK_RESPONSES_DIR / original_path + if relative_path.exists(): + final_path = relative_path + else: + raise FileNotFoundError(f"Mock data file not found: {original_path}") + + with open(final_path) as f: + return json.load(f) + + +def mock_responses( + status: Union[httpx.Response, str, Path, list[Union[str, Path, httpx.Response]], dict[str, Any], None] = None, + commands: Union[ + dict[str, Union[httpx.Response, str, Path, list[Union[str, Path, httpx.Response]], dict[str, Any]]], None + ] = None, + auth_token: str = "mock-token-12345", +): + """Test decorator that mocks HTTP API responses. + + Intercepts network requests for the FordPass API and returns predefined mock + responses instead of making actual API calls. + + Args: + status: Controls responses for status/telemetry endpoint requests. + - httpx.Response: Used directly as the response + - str or Path: Path to a JSON file, loaded and returned with 200 status code + - dict: Raw JSON data, returned with 200 status code + - list: Multiple responses returned in sequence for consecutive calls + (last item repeats for any additional calls) + - None: Returns a default baseline status from "status/baseline.json" + + commands: Controls responses for vehicle command endpoint requests. + - dict mapping command names to response definitions + - Each response can be a str/Path to JSON file, raw dict, or a list of + responses returned in sequence (like status) + - None: All command requests return 404 + + auth_token: The mock authentication token to use for all authorized requests. + + Returns: + A decorated test function that runs within the mocked HTTP environment. + + Example: + @mock_responses( + status="tests/mocks/vehicle_status.json", + commands={ + "unlock": "tests/mocks/unlock_success.json", + "lock": [ + "tests/mocks/command_accepted.json", + "tests/mocks/lock_complete.json" + ] + } + ) + def test_vehicle_commands(self): + # Test code that makes API calls, which will now use mock responses + ... + """ + + def decorator(test_func: Callable[..., T]) -> Callable[..., T]: + def get_response_data(source): + if isinstance(source, (str, Path)): + return load_mock_json(source) + return source + + command_indexes = {} + + def get_response(source_data, index_tracker): + # For non-list sources, just return the data directly + if not isinstance(source_data, list): + return get_response_data(source_data) + + # For lists, return the current item or the last one if we've reached the end + current = index_tracker["current"] + if current < len(source_data) - 1: + index_tracker["current"] = current + 1 + return get_response_data(source_data[current]) + # Return the last item for all subsequent calls + return get_response_data(source_data[-1]) + + def status_handler() -> Union[Iterable[httpx.Response], httpx.Response]: + if isinstance(status, (str, Path)): + return httpx.Response(status_code=200, json=load_mock_json(status)) + if isinstance(status, list): + responses = [ + s if isinstance(s, httpx.Response) else httpx.Response(status_code=200, json=load_mock_json(s)) + for s in status + ] + return chain(responses, repeat(responses[-1])) + if isinstance(status, dict): + return httpx.Response(status_code=200, json=status) + return httpx.Response(status_code=200, json=load_mock_json("status/baseline.json")) + + def command_handler(request): + if commands is None: + return httpx.Response(status_code=404) + + command = json.loads(request.content.decode("utf-8")).get("type") + if command not in commands: + return httpx.Response(status_code=404) + + if command not in command_indexes: + command_indexes[command] = {"current": 0} + + response_data = get_response(commands[command], command_indexes[command]) + return ( + response_data + if isinstance(response_data, httpx.Response) + else httpx.Response(status_code=200, json=response_data) + ) + + @functools.wraps(test_func) + def wrapper(*args, **kwargs): + with respx.mock(assert_all_called=False) as mock: + mock.post(FORDPASS_AUTH_URL).respond( + json={"access_token": auth_token, "token_type": "Bearer", "expires_in": 3600} + ) + mock.post(AUTONOMIC_AUTH_URL).respond( + json={"access_token": auth_token, "token_type": "Bearer", "expires_in": 3600} + ) + mock.get(re.compile(rf"{AUTONOMIC_TELEMETRY_BASE_URL}/*")).side_effect = status_handler() # type: ignore + mock.post(re.compile(rf"{AUTONOMIC_COMMAND_BASE_URL}/*")).side_effect = command_handler + return test_func(*args, **kwargs) + + return wrapper + + return decorator From 17e5328e95c649b9eb7ec05cac94722b411b2349 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 08:35:35 -0500 Subject: [PATCH 15/30] Add ruff ignores --- pyproject.toml | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce589b5..7b8b487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,9 @@ name = "vehiclepass" version = "0.3.0" description = "A Python API to manage your FordPass-enabled vehicle." readme = "README.md" -authors = [ - { name = "Austin de Coup-Crank", email = "austindcc@gmail.com" } -] +authors = [{ name = "Austin de Coup-Crank", email = "austindcc@gmail.com" }] requires-python = ">=3.9" -dependencies = [ - "httpx>=0.28.1", - "pydantic>=2.11.1", - "python-dotenv>=1.1.0", -] +dependencies = ["httpx>=0.28.1", "pydantic>=2.11.1", "python-dotenv>=1.1.0"] classifiers = [ "Development Status :: 4 - Beta", @@ -70,13 +64,7 @@ select = [ "D", # pydocstyle ] -exclude = [ - ".git", - ".venv", - "__pycache__", - "build", - "dist", -] +exclude = [".git", ".venv", "__pycache__", "build", "dist"] [tool.ruff.lint.isort] known-first-party = ["vehiclepass"] @@ -84,3 +72,7 @@ combine-as-imports = true [tool.ruff.lint.pydocstyle] convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["B009"] +"./hack.py" = ["ALL"] From 10eb0086d28716c630dc2cef5084e442e414c40c Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 08:36:44 -0500 Subject: [PATCH 16/30] Finish implementing new tires --- src/vehiclepass/_types.py | 1 + src/vehiclepass/tires.py | 5 +++-- tests/test_status.py | 41 ++++++++++++++++++++------------------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/vehiclepass/_types.py b/src/vehiclepass/_types.py index b111626..5734228 100644 --- a/src/vehiclepass/_types.py +++ b/src/vehiclepass/_types.py @@ -5,3 +5,4 @@ VehicleCommand = Literal["remoteStart", "cancelRemoteStart", "lock", "unlock"] AlarmStatus = Literal["ARMED", "DISARMED"] GearLeverPosition = Literal["PARK", "REVERSE", "NEUTRAL", "DRIVE", "MANUAL"] +TirePressureStatus = Literal["NORMAL", "LOW", "HIGH"] diff --git a/src/vehiclepass/tires.py b/src/vehiclepass/tires.py index cb45cab..a527fd9 100644 --- a/src/vehiclepass/tires.py +++ b/src/vehiclepass/tires.py @@ -3,6 +3,7 @@ import logging from typing import TYPE_CHECKING +from vehiclepass._types import TirePressureStatus from vehiclepass.errors import StatusError from vehiclepass.units import Pressure @@ -15,12 +16,12 @@ class Tire: """Represents tire status for a single tire.""" - def __init__(self, vehicle: "Vehicle", tire_position: str, pressure: Pressure, status: str) -> None: + def __init__(self, vehicle: "Vehicle", tire_position: str, pressure: Pressure, status: TirePressureStatus) -> None: """Initialize tire status for a single tire.""" self._vehicle = vehicle self._tire_position = tire_position self.pressure = pressure - self.pressure_status = status + self.status = status def __repr__(self): """Return string representation showing tire position and pressure.""" diff --git a/tests/test_status.py b/tests/test_status.py index 797ac16..d20e139 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -24,40 +24,41 @@ def test_get_metric_value(vehicle: vehiclepass.Vehicle) -> None: def test_tire_pressure(vehicle: vehiclepass.Vehicle) -> None: """Test tire pressure properties.""" assert isinstance(vehicle.tires, Tires) + assert isinstance(vehicle.tyres, Tires) # Front left - assert getattr(vehicle.tires, "front_left").pressure.psi == 39.45 # noqa: B009 - assert getattr(vehicle.tires, "front_left").pressure.bar == 2.72 # noqa: B009 - assert getattr(vehicle.tires, "front_left").pressure.kpa == 272.0 # noqa: B009 - assert str(getattr(vehicle.tires, "front_left").pressure) == "39.45 psi" # noqa: B009 + assert getattr(vehicle.tires, "front_left").pressure.psi == 39.45 + assert getattr(vehicle.tires, "front_left").pressure.bar == 2.72 + assert getattr(vehicle.tires, "front_left").pressure.kpa == 272.0 + assert str(getattr(vehicle.tires, "front_left").pressure) == "39.45 psi" # Front right - assert getattr(vehicle.tires, "front_right").pressure.psi == 40.18 # noqa: B009 - assert getattr(vehicle.tires, "front_right").pressure.kpa == 277.0 # noqa: B009 - assert getattr(vehicle.tires, "front_right").pressure.bar == 2.77 # noqa: B009 - assert str(getattr(vehicle.tires, "front_right").pressure) == "40.18 psi" # noqa: B009 + assert getattr(vehicle.tires, "front_right").pressure.psi == 40.18 + assert getattr(vehicle.tires, "front_right").pressure.kpa == 277.0 + assert getattr(vehicle.tires, "front_right").pressure.bar == 2.77 + assert str(getattr(vehicle.tires, "front_right").pressure) == "40.18 psi" # Rear left - assert getattr(vehicle.tires, "rear_left").pressure.psi == 39.89 # noqa: B009 - assert getattr(vehicle.tires, "rear_left").pressure.bar == 2.75 # noqa: B009 - assert getattr(vehicle.tires, "rear_left").pressure.kpa == 275.0 # noqa: B009 - assert str(getattr(vehicle.tires, "rear_left").pressure) == "39.89 psi" # noqa: B009 + assert getattr(vehicle.tires, "rear_left").pressure.psi == 39.89 + assert getattr(vehicle.tires, "rear_left").pressure.bar == 2.75 + assert getattr(vehicle.tires, "rear_left").pressure.kpa == 275.0 + assert str(getattr(vehicle.tires, "rear_left").pressure) == "39.89 psi" # Rear right - assert getattr(vehicle.tires, "rear_right").pressure.psi == 39.89 # noqa: B009 - assert getattr(vehicle.tires, "rear_right").pressure.bar == 2.75 # noqa: B009 - assert getattr(vehicle.tires, "rear_right").pressure.kpa == 275.0 # noqa: B009 - assert str(getattr(vehicle.tires, "rear_right").pressure) == "39.89 psi" # noqa: B009 + assert getattr(vehicle.tires, "rear_right").pressure.psi == 39.89 + assert getattr(vehicle.tires, "rear_right").pressure.bar == 2.75 + assert getattr(vehicle.tires, "rear_right").pressure.kpa == 275.0 + assert str(getattr(vehicle.tires, "rear_right").pressure) == "39.89 psi" @mock_responses(status="status/baseline.json") def test_tire_status(vehicle: vehiclepass.Vehicle) -> None: """Test tire status properties.""" assert isinstance(vehicle.tires, Tires) - assert vehicle.tires.front_left.status == "NORMAL" - assert vehicle.tires.front_right.status == "NORMAL" - assert vehicle.tires.rear_left.status == "NORMAL" - assert vehicle.tires.rear_right.status == "NORMAL" + assert getattr(vehicle.tires, "front_left").status == "NORMAL" + assert getattr(vehicle.tires, "front_right").status == "NORMAL" + assert getattr(vehicle.tires, "rear_left").status == "NORMAL" + assert getattr(vehicle.tires, "rear_right").status == "NORMAL" @mock_responses(status="status/baseline.json") From 585a02079f88d4f72c3498187e97884ba612232b Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 08:37:47 -0500 Subject: [PATCH 17/30] Convenience alias --- src/vehiclepass/vehicle.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index 7c7caeb..3e65284 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -528,6 +528,11 @@ def tires(self) -> Tires: """Get tires.""" return Tires(self) + @property + def tpms(self) -> Tires: + """Get tire pressure monitoring system.""" + return self.tires + @property def tyres(self) -> Tires: """Get tyres.""" From 6b55b225f395f5e166b99da6b775548771c46355 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 09:13:36 -0500 Subject: [PATCH 18/30] Use Pydantic models instead --- src/vehiclepass/units.py | 191 ++++++++++++++++++++++++--------------- 1 file changed, 116 insertions(+), 75 deletions(-) diff --git a/src/vehiclepass/units.py b/src/vehiclepass/units.py index 2ce0967..7c29640 100644 --- a/src/vehiclepass/units.py +++ b/src/vehiclepass/units.py @@ -1,8 +1,10 @@ -"""Unit conversion utilities.""" +"""Unit conversion utilities using Pydantic models.""" import datetime -from dataclasses import dataclass, field -from typing import Literal, TypeVar, Union +from typing import Annotated, Literal, Optional, TypeVar + +from pydantic import BaseModel, Field, NonNegativeInt +from pydantic.functional_validators import AfterValidator from vehiclepass.constants import ( DECIMAL_PLACES, @@ -18,8 +20,19 @@ TemperatureUnit = Literal["c", "f"] DistanceUnit = Literal["km", "mi"] PressureUnit = Literal["kpa", "psi"] - -unit_label_map = { +ElectricPotentialUnit = Literal["v", "mv"] +TimeUnit = Literal["h", "m", "s", "ms", "human_readable"] + +# Type aliases for better type checking +Celsius = Annotated[float, AfterValidator(lambda x: round(x, DECIMAL_PLACES))] +Kilometers = Annotated[float, AfterValidator(lambda x: round(x, DECIMAL_PLACES))] +Kilopascals = Annotated[float, AfterValidator(lambda x: round(x, DECIMAL_PLACES))] +Volts = Annotated[float, AfterValidator(lambda x: round(x, DECIMAL_PLACES))] +Seconds = Annotated[float, AfterValidator(lambda x: round(x, DECIMAL_PLACES))] +PercentValue = Annotated[float, AfterValidator(lambda x: round(x, DECIMAL_PLACES))] + +# Unit label mapping for string representations +UNIT_LABEL_MAP: dict[str, str] = { "c": "°C", "f": "°F", "km": "km", @@ -34,182 +47,203 @@ } -@dataclass(frozen=True) -class Temperature: +class UnitModel(BaseModel): + """Base model for unit conversion models.""" + + decimal_places: Optional[int] = Field(default=DECIMAL_PLACES, exclude=True) + _units: tuple[str, ...] = () + model_config = { + "frozen": True, + } + + def round_value(self, value: float, decimal_places: Optional[NonNegativeInt] = None) -> float: + """Round a value to the specified decimal places.""" + return round(value, decimal_places or self.decimal_places or DECIMAL_PLACES) + + @property + def data(self) -> dict[str, float]: + """Get all data units in a dictionary.""" + return {unit: getattr(self, unit) for unit in self._units} + + +class Temperature(UnitModel): """Temperature value with unit conversion capabilities.""" - celsius: float - _decimal_places: int = field(default=DECIMAL_PLACES) + celsius: Celsius + _units = ("c", "f") @property def c(self) -> float: """Get temperature in Celsius.""" - return round(self.celsius, self._decimal_places) + return self.round_value(self.celsius) @property def f(self) -> float: """Get temperature in Fahrenheit.""" - return round((self.celsius * 9 / 5) + 32, self._decimal_places) + return self.round_value((self.celsius * 9 / 5) + 32) @classmethod - def from_celsius(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "Temperature": + def from_celsius(cls, value: float, decimal_places: Optional[int] = None) -> "Temperature": """Create a Temperature instance from a Celsius value.""" - return cls(value, decimal_places) + return cls(celsius=value, decimal_places=decimal_places) @classmethod - def from_fahrenheit(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "Temperature": + def from_fahrenheit(cls, value: float, decimal_places: Optional[int] = None) -> "Temperature": """Create a Temperature instance from a Fahrenheit value.""" - return cls((value - 32) * 5 / 9, decimal_places) + return cls(celsius=(value - 32) * 5 / 9, decimal_places=decimal_places) def __str__(self) -> str: """Return a string representation of the temperature.""" - return f"{getattr(self, DEFAULT_TEMP_UNIT)}{unit_label_map[DEFAULT_TEMP_UNIT]}" + value = getattr(self, DEFAULT_TEMP_UNIT) + unit = UNIT_LABEL_MAP[DEFAULT_TEMP_UNIT] + return f"{value}{unit}" -@dataclass(frozen=True) -class Distance: +class Distance(UnitModel): """Distance value with unit conversion capabilities.""" - kilometers: float - _decimal_places: int = field(default=DECIMAL_PLACES) + kilometers: Kilometers + _units = ("km", "mi") @property def km(self) -> float: """Get distance in kilometers.""" - return round(self.kilometers, self._decimal_places) + return self.round_value(self.kilometers) @property def mi(self) -> float: """Get distance in miles.""" - return round(self.kilometers * 0.621371, self._decimal_places) + return self.round_value(self.kilometers * 0.621371) @classmethod - def from_kilometers(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "Distance": + def from_kilometers(cls, value: float, decimal_places: Optional[int] = None) -> "Distance": """Create a Distance instance from a kilometers value.""" - return cls(value, decimal_places) + return cls(kilometers=value, decimal_places=decimal_places) @classmethod - def from_miles(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "Distance": + def from_miles(cls, value: float, decimal_places: Optional[int] = None) -> "Distance": """Create a Distance instance from a miles value.""" - return cls(value / 0.621371, decimal_places) + return cls(kilometers=value / 0.621371, decimal_places=decimal_places) def __str__(self) -> str: """Return a string representation of the distance.""" - return f"{getattr(self, DEFAULT_DISTANCE_UNIT)} {unit_label_map[DEFAULT_DISTANCE_UNIT]}" + value = getattr(self, DEFAULT_DISTANCE_UNIT) + unit = UNIT_LABEL_MAP[DEFAULT_DISTANCE_UNIT] + return f"{value} {unit}" -@dataclass(frozen=True) -class Pressure: +class Pressure(UnitModel): """Pressure value with unit conversion capabilities.""" - kilopascals: float - _decimal_places: int = field(default=DECIMAL_PLACES) + kilopascals: Kilopascals + _units = ("kpa", "psi", "bar") @property def bar(self) -> float: """Get pressure in bars.""" - return round(self.kilopascals / 100, self._decimal_places) + return self.round_value(self.kilopascals / 100) @property def kpa(self) -> float: """Get pressure in kilopascals.""" - return round(self.kilopascals, self._decimal_places) + return self.round_value(self.kilopascals) @property def psi(self) -> float: """Get pressure in pounds per square inch.""" - return round(self.kilopascals * 0.145038, self._decimal_places) + return self.round_value(self.kilopascals * 0.145038) @classmethod - def from_kilopascals(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "Pressure": + def from_kilopascals(cls, value: float, decimal_places: Optional[int] = None) -> "Pressure": """Create a Pressure instance from a kilopascals value.""" - return cls(value, decimal_places) + return cls(kilopascals=value, decimal_places=decimal_places) @classmethod - def from_psi(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "Pressure": + def from_psi(cls, value: float, decimal_places: Optional[int] = None) -> "Pressure": """Create a Pressure instance from a psi value.""" - return cls(value / 0.145038, decimal_places) + return cls(kilopascals=value / 0.145038, decimal_places=decimal_places) def __str__(self) -> str: """Return a string representation of the pressure.""" - return f"{getattr(self, DEFAULT_PRESSURE_UNIT)} {unit_label_map[DEFAULT_PRESSURE_UNIT]}" + value = getattr(self, DEFAULT_PRESSURE_UNIT) + unit = UNIT_LABEL_MAP[DEFAULT_PRESSURE_UNIT] + return f"{value} {unit}" -@dataclass(frozen=True) -class ElectricPotential: +class ElectricPotential(UnitModel): """Electric potential value with unit conversion capabilities.""" - volts: Union[float, int] - _decimal_places: int = field(default=DECIMAL_PLACES) + volts: Volts + _units = ("v", "mv") @property def v(self) -> float: """Get electric potential in volts.""" - return round(self.volts, self._decimal_places) + return self.round_value(self.volts) @property def mv(self) -> float: """Get electric potential in millivolts.""" - return round(self.volts * 1000, self._decimal_places) + return self.round_value(self.volts * 1000) @classmethod - def from_volts(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "ElectricPotential": + def from_volts(cls, value: float, decimal_places: Optional[int] = None) -> "ElectricPotential": """Create an ElectricPotential instance from a volts value.""" - return cls(value, decimal_places) + return cls(volts=value, decimal_places=decimal_places) @classmethod - def from_millivolts(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "ElectricPotential": + def from_millivolts(cls, value: float, decimal_places: Optional[int] = None) -> "ElectricPotential": """Create an ElectricPotential instance from a millivolts value.""" - return cls(value / 1000, decimal_places) + return cls(volts=value / 1000, decimal_places=decimal_places) def __str__(self) -> str: """Return a string representation of the electric potential.""" - return f"{getattr(self, DEFAULT_ELECTRIC_POTENTIAL_UNIT)} {unit_label_map[DEFAULT_ELECTRIC_POTENTIAL_UNIT]}" + value = getattr(self, DEFAULT_ELECTRIC_POTENTIAL_UNIT) + unit = UNIT_LABEL_MAP[DEFAULT_ELECTRIC_POTENTIAL_UNIT] + return f"{value} {unit}" -@dataclass(frozen=True) -class Percentage: +class Percentage(UnitModel): """Percentage value.""" - percentage: float - _decimal_places: int = field(default=DECIMAL_PLACES) + value: float = Field(alias="percentage") @property def percent(self) -> float: - """Get percentage.""" - return round(self.percentage, self._decimal_places) + """Get the rounded percentage value.""" + # Since percents are already decimals, we need to add 2 more decimal places to the value + return self.round_value(self.value, decimal_places=self.decimal_places + 2 if self.decimal_places else None) def __str__(self) -> str: """Return a string representation of the percentage.""" return f"{self.percent * 100}%" -@dataclass(frozen=True) -class Duration: +class Duration(UnitModel): """Time duration.""" - seconds: float - _decimal_places: int = field(default=DECIMAL_PLACES) + seconds: Seconds + _units = ("h", "m", "s", "ms", "human_readable") @property def h(self) -> float: """Get duration in hours.""" - return round(self.seconds / 3600, self._decimal_places) + return self.round_value(self.seconds / 3600) @property def m(self) -> float: """Get duration in minutes.""" - return round(self.seconds / 60, self._decimal_places) + return self.round_value(self.seconds / 60) @property def s(self) -> float: """Get duration in seconds.""" - return round(self.seconds, self._decimal_places) + return self.round_value(self.seconds) @property def ms(self) -> float: """Get duration in milliseconds.""" - return round(self.seconds * 1000, self._decimal_places) + return self.round_value(self.seconds * 1000) @property def delta(self) -> datetime.timedelta: @@ -221,21 +255,28 @@ def human_readable(self) -> str: """Get duration in human readable format.""" parts = [] if self.h >= 1: - parts.append(f"{round(self.h)}h") - if self.m > 0: - parts.append(f"{round(self.m)}m") - remaining_seconds = self.seconds % 60 # Calculate remaining seconds - if remaining_seconds > 0: - parts.append(f"{round(remaining_seconds)}s") - return " ".join(parts) if parts else "0s" + parts.append(f"{int(self.h)}h") + + minutes = int(self.m) % 60 + if minutes > 0: + parts.append(f"{minutes}m") + + remaining_seconds = int(self.seconds) % 60 + if remaining_seconds > 0 or not parts: + parts.append(f"{remaining_seconds}s") + + return " ".join(parts) @classmethod - def from_seconds(cls, value: float, decimal_places: int = DECIMAL_PLACES) -> "Duration": - """Create a Time instance from a seconds value.""" - return cls(value, decimal_places) + def from_seconds(cls, value: float, decimal_places: Optional[int] = None) -> "Duration": + """Create a Duration instance from a seconds value.""" + return cls(seconds=value, decimal_places=decimal_places) def __str__(self) -> str: """Return a string representation of the time.""" if DEFAULT_TIME_UNIT == "human_readable": return self.human_readable - return f"{getattr(self, DEFAULT_TIME_UNIT)} {unit_label_map[DEFAULT_TIME_UNIT]}" + + value = getattr(self, DEFAULT_TIME_UNIT) + unit = UNIT_LABEL_MAP[DEFAULT_TIME_UNIT] + return f"{value} {unit}" From 40b2c7eb60eb61b4afd38df423a1b9df86e2952b Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 09:13:48 -0500 Subject: [PATCH 19/30] Ruff/mypy fixes --- CHANGELOG.md | 4 ++-- src/vehiclepass/tires.py | 17 +++++++++++++---- src/vehiclepass/vehicle.py | 4 ++-- tests/test_doors.py | 12 ++++++------ .../{test_status.py => test_vehicle_status.py} | 15 ++++++++++++--- 5 files changed, 35 insertions(+), 17 deletions(-) rename tests/{test_status.py => test_vehicle_status.py} (86%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c517a..04dae9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # v0.4.0 * Add several tests -* Add TPMS status (`vehicle.tire_pressure.system_status`) -* +* Add TPMS status (`vehicle.tires.system_status`) +* Refactor tires to include TPMS status for each tire # v0.3.0 diff --git a/src/vehiclepass/tires.py b/src/vehiclepass/tires.py index a527fd9..2d71568 100644 --- a/src/vehiclepass/tires.py +++ b/src/vehiclepass/tires.py @@ -42,7 +42,7 @@ def __init__(self, vehicle: "Vehicle") -> None: for tire in self._vehicle._get_metric_value("tirePressure", list): tire_position = tire["vehicleWheel"].lower() logger.debug("Tire position: %s", tire_position) - self._tires[tire_position] = {"pressure": tire["value"]} + self._tires[tire_position] = {"pressure": Pressure.from_kilopascals(tire["value"])} for tire in self._vehicle._get_metric_value("tirePressureStatus", list): tire_position = tire["vehicleWheel"].lower() @@ -55,19 +55,28 @@ def __init__(self, vehicle: "Vehicle") -> None: Tire( self._vehicle, tire_position, - pressure=Pressure.from_kilopascals(data["pressure"]), - status=data["status"], + pressure=data["pressure"], + # Seems to trigger a mypy bug + status=data["status"], # type: ignore ), ) @property def system_status(self) -> str: - """Get the system status of the tire pressure monitoringsystem.""" + """Get the status of the tire pressure monitoring system.""" try: return self._vehicle._get_metric_value("tirePressureSystemStatus", list)[0]["value"] except (IndexError, KeyError) as exc: raise StatusError("Tire pressure monitoring system status not found") from exc + @property + def data(self) -> dict: + """Get the pressure and status data for all tires.""" + return { + tire_position: {"pressure": tire["pressure"].data, "status": tire["status"]} + for tire_position, tire in self._tires.items() + } + def __repr__(self): """Return string representation showing available tire positions.""" positions, values = zip(*self._tires.items()) diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index 3e65284..a550a2b 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -400,7 +400,7 @@ def alarm_status(self) -> AlarmStatus: @property def battery_charge(self) -> Percentage: """Get the battery charge percentage.""" - return Percentage(self._get_metric_value("batteryStateOfCharge", float) / 100) + return Percentage(percentage=self._get_metric_value("batteryStateOfCharge", float) / 100) @property def battery_voltage(self) -> ElectricPotential: @@ -425,7 +425,7 @@ def engine_coolant_temp(self) -> Temperature: @property def fuel_level(self) -> Percentage: """Get the fuel level as a percentage.""" - return Percentage(self._get_metric_value("fuelLevel", float) / 100) + return Percentage(percentage=self._get_metric_value("fuelLevel", float) / 100) @property def fuel_range(self) -> Distance: diff --git a/tests/test_doors.py b/tests/test_doors.py index fc81bc4..b9ea8ce 100644 --- a/tests/test_doors.py +++ b/tests/test_doors.py @@ -13,12 +13,12 @@ def test_doors_closed(vehicle: vehiclepass.Vehicle) -> None: assert vehicle.doors.are_locked is False # We call these with getattr to avoid mypy errors, as doors are dynamically # created in the Doors class. - assert getattr(vehicle.doors, "front_left") == "CLOSED" # noqa: B009 - assert getattr(vehicle.doors, "front_right") == "CLOSED" # noqa: B009 - assert getattr(vehicle.doors, "rear_left") == "CLOSED" # noqa: B009 - assert getattr(vehicle.doors, "rear_right") == "CLOSED" # noqa: B009 - assert getattr(vehicle.doors, "tailgate") == "CLOSED" # noqa: B009 - assert getattr(vehicle.doors, "inner_tailgate") == "CLOSED" # noqa: B009 + assert getattr(vehicle.doors, "front_left") == "CLOSED" + assert getattr(vehicle.doors, "front_right") == "CLOSED" + assert getattr(vehicle.doors, "rear_left") == "CLOSED" + assert getattr(vehicle.doors, "rear_right") == "CLOSED" + assert getattr(vehicle.doors, "tailgate") == "CLOSED" + assert getattr(vehicle.doors, "inner_tailgate") == "CLOSED" @mock_responses( diff --git a/tests/test_status.py b/tests/test_vehicle_status.py similarity index 86% rename from tests/test_status.py rename to tests/test_vehicle_status.py index d20e139..1b9ca44 100644 --- a/tests/test_status.py +++ b/tests/test_vehicle_status.py @@ -31,6 +31,7 @@ def test_tire_pressure(vehicle: vehiclepass.Vehicle) -> None: assert getattr(vehicle.tires, "front_left").pressure.bar == 2.72 assert getattr(vehicle.tires, "front_left").pressure.kpa == 272.0 assert str(getattr(vehicle.tires, "front_left").pressure) == "39.45 psi" + assert getattr(vehicle.tires, "front_left").pressure.data == {"psi": 39.45, "bar": 2.72, "kpa": 272.0} # Front right assert getattr(vehicle.tires, "front_right").pressure.psi == 40.18 @@ -65,6 +66,14 @@ def test_tire_status(vehicle: vehiclepass.Vehicle) -> None: def test_tires_system_status(vehicle: vehiclepass.Vehicle) -> None: """Test tire pressure system status.""" assert vehicle.tires.system_status == "NORMAL_OPERATION" + assert vehicle.tpms.system_status == "NORMAL_OPERATION" + assert vehicle.tyres.system_status == "NORMAL_OPERATION" + assert vehicle.tires.data == { + "front_left": {"pressure": {"psi": 39.45, "bar": 2.72, "kpa": 272.0}, "status": "NORMAL"}, + "front_right": {"pressure": {"psi": 40.18, "bar": 2.77, "kpa": 277.0}, "status": "NORMAL"}, + "rear_left": {"pressure": {"psi": 39.89, "bar": 2.75, "kpa": 275.0}, "status": "NORMAL"}, + "rear_right": {"pressure": {"psi": 39.89, "bar": 2.75, "kpa": 275.0}, "status": "NORMAL"}, + } @mock_responses(status="status/baseline.json") @@ -94,9 +103,9 @@ def test_odometer(vehicle: vehiclepass.Vehicle) -> None: def test_fuel(vehicle: vehiclepass.Vehicle) -> None: """Test fuel properties.""" assert isinstance(vehicle.fuel_level, units.Percentage) - assert vehicle.fuel_level.percentage == 0.72717624 - assert vehicle.fuel_level.percent == 0.73 - assert str(vehicle.fuel_level) == "73.0%" + assert vehicle.fuel_level.value == 0.72717624 + assert vehicle.fuel_level.percent == 0.7272 + assert str(vehicle.fuel_level) == "72.72%" @mock_responses(status="status/remotely_started.json") From f0af1306f17a8ced13539e477c4f8f0d9e2eec05 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Mon, 7 Apr 2025 09:14:41 -0500 Subject: [PATCH 20/30] update changelog --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04dae9b..47fa0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # v0.4.0 -* Add several tests -* Add TPMS status (`vehicle.tires.system_status`) -* Refactor tires to include TPMS status for each tire +* Added several tests +* Added TPMS status (`vehicle.tires.system_status`) +* Refactored tires to include TPMS status for each tire (`vehicle.tires.front_right.status`) # v0.3.0 From 1812629e4af5e75874ac906a2df2d29717b721af Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Tue, 8 Apr 2025 07:01:32 -0500 Subject: [PATCH 21/30] More tire pressure tests --- tests/test_vehicle_status.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_vehicle_status.py b/tests/test_vehicle_status.py index 1b9ca44..977e7ba 100644 --- a/tests/test_vehicle_status.py +++ b/tests/test_vehicle_status.py @@ -27,28 +27,31 @@ def test_tire_pressure(vehicle: vehiclepass.Vehicle) -> None: assert isinstance(vehicle.tyres, Tires) # Front left - assert getattr(vehicle.tires, "front_left").pressure.psi == 39.45 assert getattr(vehicle.tires, "front_left").pressure.bar == 2.72 assert getattr(vehicle.tires, "front_left").pressure.kpa == 272.0 + assert getattr(vehicle.tires, "front_left").pressure.psi == 39.45 assert str(getattr(vehicle.tires, "front_left").pressure) == "39.45 psi" assert getattr(vehicle.tires, "front_left").pressure.data == {"psi": 39.45, "bar": 2.72, "kpa": 272.0} # Front right - assert getattr(vehicle.tires, "front_right").pressure.psi == 40.18 - assert getattr(vehicle.tires, "front_right").pressure.kpa == 277.0 assert getattr(vehicle.tires, "front_right").pressure.bar == 2.77 + assert getattr(vehicle.tires, "front_right").pressure.kpa == 277.0 + assert getattr(vehicle.tires, "front_right").pressure.psi == 40.18 assert str(getattr(vehicle.tires, "front_right").pressure) == "40.18 psi" + assert getattr(vehicle.tires, "front_right").pressure.data == {"psi": 40.18, "bar": 2.77, "kpa": 277.0} # Rear left assert getattr(vehicle.tires, "rear_left").pressure.psi == 39.89 assert getattr(vehicle.tires, "rear_left").pressure.bar == 2.75 assert getattr(vehicle.tires, "rear_left").pressure.kpa == 275.0 assert str(getattr(vehicle.tires, "rear_left").pressure) == "39.89 psi" + assert getattr(vehicle.tires, "rear_left").pressure.data == {"psi": 39.89, "bar": 2.75, "kpa": 275.0} # Rear right - assert getattr(vehicle.tires, "rear_right").pressure.psi == 39.89 assert getattr(vehicle.tires, "rear_right").pressure.bar == 2.75 assert getattr(vehicle.tires, "rear_right").pressure.kpa == 275.0 + assert getattr(vehicle.tires, "rear_right").pressure.psi == 39.89 + assert getattr(vehicle.tires, "rear_right").pressure.data == {"psi": 39.89, "bar": 2.75, "kpa": 275.0} assert str(getattr(vehicle.tires, "rear_right").pressure) == "39.89 psi" From 3bab78804bc8e5d458579375f666a578d1fcb5c8 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Tue, 8 Apr 2025 07:18:00 -0500 Subject: [PATCH 22/30] Add vehicle.position --- CHANGELOG.md | 1 + pyproject.toml | 7 ++++++- src/vehiclepass/vehicle.py | 13 +++++++++++++ tests/test_vehicle_status.py | 9 +++++++++ uv.lock | 15 +++++++++++++++ 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fa0c7..b77b1c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Added several tests * Added TPMS status (`vehicle.tires.system_status`) +* Added GPS coordinates (`vehicle.position`) * Refactored tires to include TPMS status for each tire (`vehicle.tires.front_right.status`) # v0.3.0 diff --git a/pyproject.toml b/pyproject.toml index 7b8b487..903dccf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,12 @@ description = "A Python API to manage your FordPass-enabled vehicle." readme = "README.md" authors = [{ name = "Austin de Coup-Crank", email = "austindcc@gmail.com" }] requires-python = ">=3.9" -dependencies = ["httpx>=0.28.1", "pydantic>=2.11.1", "python-dotenv>=1.1.0"] +dependencies = [ + "httpx>=0.28.1", + "pydantic>=2.11.1", + "pydantic-extra-types>=2.10.3", + "python-dotenv>=1.1.0", +] classifiers = [ "Development Status :: 4 - Beta", diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index a550a2b..082bd7c 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -11,6 +11,7 @@ import httpx from dotenv import load_dotenv from pydantic import NonNegativeInt +from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude from vehiclepass._types import AlarmStatus, CompassDirection, GearLeverPosition, HoodStatus, VehicleCommand from vehiclepass.constants import ( @@ -499,6 +500,18 @@ def outside_temp(self) -> Temperature: """ return Temperature.from_celsius(self._get_metric_value("outsideTemperature", float)) + @property + def position(self) -> Coordinate: + """Get vehicle location (latitude/longitude).""" + position = self._get_metric_value("position", dict) + try: + return Coordinate( + latitude=Latitude(round(position["location"]["lat"], 5)), + longitude=Longitude(round(position["location"]["lon"], 5)), + ) + except KeyError as exc: + raise StatusError("Unable to find vehicle position.") from exc + @property def rpm(self) -> NonNegativeInt: """Get the engine's current RPM.""" diff --git a/tests/test_vehicle_status.py b/tests/test_vehicle_status.py index 977e7ba..07d615d 100644 --- a/tests/test_vehicle_status.py +++ b/tests/test_vehicle_status.py @@ -1,6 +1,7 @@ """Tests vehicle status using respx for mocking HTTP requests.""" import pytest +from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude from respx import MockRouter import vehiclepass @@ -121,6 +122,14 @@ def test_status_remotely_started(vehicle: vehiclepass.Vehicle) -> None: assert vehicle.is_ignition_started is False +@mock_responses(status="status/baseline.json") +def test_position(vehicle: vehiclepass.Vehicle) -> None: + """Test vehicle position (GPS coordinates).""" + assert isinstance(vehicle.position, Coordinate) + assert vehicle.position.latitude == Latitude(46.37414) + assert vehicle.position.longitude == Longitude(-94.78723) + + @pytest.mark.parametrize("temp_c, temp_f", [(0, 32), (20, 68), (37, 98.6), (-10, 14)]) def test_temperature_conversion(vehicle: vehiclepass.Vehicle, mock_router: MockRouter, temp_c: float, temp_f: float): """Test that temperature conversions work correctly.""" diff --git a/uv.lock b/uv.lock index 12faf5b..dae74d2 100644 --- a/uv.lock +++ b/uv.lock @@ -601,6 +601,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/f2/1647933efaaad61846109a27619f3704929e758a09e6431b8f932a053d40/pydantic_core-2.33.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3", size = 2081073 }, ] +[[package]] +name = "pydantic-extra-types" +version = "2.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/fa/6b268a47839f8af46ffeb5bb6aee7bded44fbad54e6bf826c11f17aef91a/pydantic_extra_types-2.10.3.tar.gz", hash = "sha256:dcc0a7b90ac9ef1b58876c9b8fdede17fbdde15420de9d571a9fccde2ae175bb", size = 95128 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0a/f6f8e5f79d188e2f3fa9ecfccfa72538b685985dd5c7c2886c67af70e685/pydantic_extra_types-2.10.3-py3-none-any.whl", hash = "sha256:e8b372752b49019cd8249cc192c62a820d8019f5382a8789d0f887338a59c0f3", size = 37175 }, +] + [[package]] name = "pygments" version = "2.19.1" @@ -772,6 +785,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "pydantic" }, + { name = "pydantic-extra-types" }, { name = "python-dotenv" }, ] @@ -789,6 +803,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.11.1" }, + { name = "pydantic-extra-types", specifier = ">=2.10.3" }, { name = "python-dotenv", specifier = ">=1.1.0" }, ] From e07252a218dfa2d2a7b355fe7b42bd0e6dd65600 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Tue, 8 Apr 2025 07:19:07 -0500 Subject: [PATCH 23/30] Begin location --- src/vehiclepass/vehicle.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index 082bd7c..8ca66a6 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -486,6 +486,11 @@ def is_running(self) -> bool: """Check if the vehicle is running, either from the ignition or a remote start command.""" return self.is_ignition_started or self.is_remotely_started + @property + def location(self): + """Get detailed location data.""" + raise NotImplementedError("location not yet implemented") + @property def odometer(self) -> Distance: """Get the odometer reading.""" From f8a9cd7a1500093cf9861802c8c34ce7df1ba1a5 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Wed, 9 Apr 2025 06:51:16 -0500 Subject: [PATCH 24/30] Add seatbelts status --- src/vehiclepass/seatbelts.py | 36 ++++++++++++++++++++++++++++++++++++ src/vehiclepass/vehicle.py | 6 ++++++ tests/test_vehicle_status.py | 13 +++++++++++-- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/vehiclepass/seatbelts.py diff --git a/src/vehiclepass/seatbelts.py b/src/vehiclepass/seatbelts.py new file mode 100644 index 0000000..68a3f5a --- /dev/null +++ b/src/vehiclepass/seatbelts.py @@ -0,0 +1,36 @@ +"""Seatbelt status.""" + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from vehiclepass.vehicle import Vehicle + +logger = logging.getLogger(__name__) + + +class SeatBelts: + """Seatbelt status.""" + + def __init__(self, vehicle: "Vehicle"): + """Initialize seatbelt status readings from status data and dynamically create properties. + + Args: + vehicle: Parent vehicle object + + Raises: + None + """ + self._vehicle = vehicle + self._seatbelts = {} + self._seatbelt_status = self._vehicle._get_metric_value("seatBeltStatus", list) + + for seatbelt in self._seatbelt_status: + position = seatbelt.get("vehicleOccupantRole", "").lower() + self._seatbelts[position] = seatbelt["value"] + setattr(self, position, seatbelt["value"]) + + def __repr__(self) -> str: + """Return string representation showing available seatbelts.""" + positions = list(self._seatbelts.keys()) + return f"SeatBelts(seats={positions})" diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index 8ca66a6..4e768e3 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -26,6 +26,7 @@ from vehiclepass.doors import Doors from vehiclepass.errors import CommandError, StatusError from vehiclepass.indicators import Indicators +from vehiclepass.seatbelts import SeatBelts from vehiclepass.tires import Tires from vehiclepass.units import Distance, Duration, ElectricPotential, Percentage, Temperature @@ -522,6 +523,11 @@ def rpm(self) -> NonNegativeInt: """Get the engine's current RPM.""" return self._get_metric_value("engineSpeed", int) + @property + def seatbelts(self) -> SeatBelts: + """Get seatbelt status.""" + return SeatBelts(self) + @property def shutoff_countdown(self) -> Duration: """Get the vehicle shutoff time in seconds.""" diff --git a/tests/test_vehicle_status.py b/tests/test_vehicle_status.py index 07d615d..baf4f1f 100644 --- a/tests/test_vehicle_status.py +++ b/tests/test_vehicle_status.py @@ -8,6 +8,7 @@ from tests.utils import mock_responses from vehiclepass import units from vehiclepass.constants import AUTONOMIC_AUTH_URL, AUTONOMIC_TELEMETRY_BASE_URL +from vehiclepass.seatbelts import SeatBelts from vehiclepass.tires import Tires @@ -66,6 +67,14 @@ def test_tire_status(vehicle: vehiclepass.Vehicle) -> None: assert getattr(vehicle.tires, "rear_right").status == "NORMAL" +@mock_responses(status="status/baseline.json") +def test_seatbelts(vehicle: vehiclepass.Vehicle) -> None: + """Test seatbelt status.""" + assert isinstance(vehicle.seatbelts, SeatBelts) + assert getattr(vehicle.seatbelts, "driver") == "UNBUCKLED" + assert getattr(vehicle.seatbelts, "passenger") == "UNBUCKLED" + + @mock_responses(status="status/baseline.json") def test_tires_system_status(vehicle: vehiclepass.Vehicle) -> None: """Test tire pressure system status.""" @@ -126,8 +135,8 @@ def test_status_remotely_started(vehicle: vehiclepass.Vehicle) -> None: def test_position(vehicle: vehiclepass.Vehicle) -> None: """Test vehicle position (GPS coordinates).""" assert isinstance(vehicle.position, Coordinate) - assert vehicle.position.latitude == Latitude(46.37414) - assert vehicle.position.longitude == Longitude(-94.78723) + assert vehicle.position.latitude == Latitude(42.31474) + assert vehicle.position.longitude == Longitude(-83.21043) @pytest.mark.parametrize("temp_c, temp_f", [(0, 32), (20, 68), (37, 98.6), (-10, 14)]) From 64ec8ce2916e5ea226dd3a6a877fc5a2ef05604f Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Wed, 9 Apr 2025 06:51:44 -0500 Subject: [PATCH 25/30] Docstring --- src/vehiclepass/doors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vehiclepass/doors.py b/src/vehiclepass/doors.py index a47ef15..76f8644 100644 --- a/src/vehiclepass/doors.py +++ b/src/vehiclepass/doors.py @@ -21,7 +21,7 @@ def __init__(self, vehicle: "Vehicle") -> None: vehicle: Parent vehicle object Raises: - StatusError: If status_data is None or empty + None """ self._vehicle = vehicle self._doors = {} From 795e05a2a66555a4125088efdad6f4309fbe6794 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Wed, 9 Apr 2025 06:56:37 -0500 Subject: [PATCH 26/30] Add oil life remaining --- src/vehiclepass/vehicle.py | 5 +++++ tests/test_vehicle_status.py | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/src/vehiclepass/vehicle.py b/src/vehiclepass/vehicle.py index 4e768e3..d448748 100644 --- a/src/vehiclepass/vehicle.py +++ b/src/vehiclepass/vehicle.py @@ -497,6 +497,11 @@ def odometer(self) -> Distance: """Get the odometer reading.""" return Distance.from_kilometers(self._get_metric_value("odometer", float)) + @property + def oil_life_remaining(self) -> Percentage: + """Get oil life remaining as a percentage.""" + return Percentage(percentage=self._get_metric_value("oilLifeRemaining", float) / 100) + @property def outside_temp(self) -> Temperature: """Get the outside temperature using the configured unit preferences. diff --git a/tests/test_vehicle_status.py b/tests/test_vehicle_status.py index baf4f1f..a4acf8a 100644 --- a/tests/test_vehicle_status.py +++ b/tests/test_vehicle_status.py @@ -112,6 +112,14 @@ def test_odometer(vehicle: vehiclepass.Vehicle) -> None: assert str(vehicle.odometer) == "65583.84 mi" +@mock_responses(status="status/baseline.json") +def test_oil_life_remaining(vehicle: vehiclepass.Vehicle) -> None: + """Test oil life remaining.""" + assert isinstance(vehicle.oil_life_remaining, units.Percentage) + assert vehicle.oil_life_remaining.value == 0.51 + assert str(vehicle.oil_life_remaining) == "51.0%" + + @mock_responses(status="status/baseline.json") def test_fuel(vehicle: vehiclepass.Vehicle) -> None: """Test fuel properties.""" From b4aa78d882d699f7c0ed2ae6a0900c0927c41382 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Wed, 9 Apr 2025 06:57:10 -0500 Subject: [PATCH 27/30] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b77b1c5..a21f8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Added several tests * Added TPMS status (`vehicle.tires.system_status`) * Added GPS coordinates (`vehicle.position`) +* Added oil life remaining (`vehicle.oil_life_remaining`) +* Added seatbelt status (`vehicle.seatbelts`) * Refactored tires to include TPMS status for each tire (`vehicle.tires.front_right.status`) # v0.3.0 From cf22a3db8973199531eae882b36f890b914ac2bc Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 20 Apr 2025 08:42:39 -0500 Subject: [PATCH 28/30] credits --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index bddc9c0..a484cd8 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,7 @@ Contributions welcome! Please [open an issue](https://github.com/austind/vehicle nox -r # The -r flag re-uses virtualenvs for faster re-runs ``` 1. [Open a pull request](https://github.com/austind/vehiclepass/compare) + +## Credits + +Based on [IOS shortcuts](https://www.reddit.com/r/f150/comments/s6cs3r/siri_shortcuts_for_fordpass_fixed/) originally developed by [u/d4v3y0rk](https://reddit.com/u/d4v3y0rk) and maintained by [/u/tinybtg](https://reddit.com/u/tinybtg)'s [IOS shortcuts](). From 086826f1b416c64756a6fbc7fbb7a2ec2b519136 Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 20 Apr 2025 08:43:34 -0500 Subject: [PATCH 29/30] tweak --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a484cd8..b3a92c2 100644 --- a/README.md +++ b/README.md @@ -87,4 +87,4 @@ Contributions welcome! Please [open an issue](https://github.com/austind/vehicle ## Credits -Based on [IOS shortcuts](https://www.reddit.com/r/f150/comments/s6cs3r/siri_shortcuts_for_fordpass_fixed/) originally developed by [u/d4v3y0rk](https://reddit.com/u/d4v3y0rk) and maintained by [/u/tinybtg](https://reddit.com/u/tinybtg)'s [IOS shortcuts](). +Based on [IOS shortcuts](https://www.reddit.com/r/f150/comments/s6cs3r/siri_shortcuts_for_fordpass_fixed/) originally developed by [u/d4v3y0rk](https://reddit.com/u/d4v3y0rk) and maintained by [/u/tinybtg](https://reddit.com/u/tinybtg). From 5df2d9deb2253273f2c2af4de0e826c7f86bc94c Mon Sep 17 00:00:00 2001 From: Austin de Coup-Crank Date: Sun, 20 Apr 2025 08:45:39 -0500 Subject: [PATCH 30/30] update credits --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b3a92c2..c9a27e2 100644 --- a/README.md +++ b/README.md @@ -87,4 +87,4 @@ Contributions welcome! Please [open an issue](https://github.com/austind/vehicle ## Credits -Based on [IOS shortcuts](https://www.reddit.com/r/f150/comments/s6cs3r/siri_shortcuts_for_fordpass_fixed/) originally developed by [u/d4v3y0rk](https://reddit.com/u/d4v3y0rk) and maintained by [/u/tinybtg](https://reddit.com/u/tinybtg). +Based on [IOS shortcuts](https://www.reddit.com/r/f150/comments/s6cs3r/siri_shortcuts_for_fordpass_fixed/) originally developed by [u/d4v3y0rk](https://reddit.com/u/d4v3y0rk) and [/u/tinybtg](https://reddit.com/u/tinybtg), and currently maintained by [Brandon Grant](https://brandongrant.dev/).