diff --git a/REFERENCE.md b/REFERENCE.md index b53e226e..0de0d868 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -358,6 +358,31 @@ the [exceptions section](https://github.com/smartcar/python-sdk#handling-excepti --- +### `service_history(self, start_date: Optional[str] = None, end_date: Optional[str] = None)` + +Returns a list of all the service records performed on the vehicle, filtered by the optional date range. If no dates are specified, records from the last year are returned. + +#### Args + +| Argument | Type | Description | +| :----------- | :------------ | :------------------------------------------------------------------------------------------ | +| `start_date` | Optional[str] | The start date for the record filter, in 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS.SSSZ' format. | +| `end_date` | Optional[str] | The end date for the record filter, similar format to start_date. | + +#### Return + +| Value | Type | Description | +| :--------------------- | :--------------------- | :------------------------------------------------------------------------- | +| `ServiceHistory` | typing.NamedTuple | The returned object with a list of service entries. | +| `ServiceHistory.items` | List[ServiceRecord] | List of service records describing maintenance activities. | +| `ServiceHistory.meta` | collections.namedtuple | Smartcar response headers (`request_id`, `data_age`, and/or `unit_system`) | + +#### Raises + +`SmartcarException` - See the [exceptions section](https://github.com/smartcar/python-sdk#handling-exceptions) for all possible exceptions. + +--- + ### `attributes(self)` Returns a single vehicle object, containing identifying information. diff --git a/smartcar/types.py b/smartcar/types.py index 263a880f..a721dc96 100644 --- a/smartcar/types.py +++ b/smartcar/types.py @@ -1,6 +1,6 @@ import datetime from collections import namedtuple -from typing import List, NamedTuple, Union +from typing import List, Optional, NamedTuple, Union import re import requests.structures as rs import enum @@ -186,6 +186,35 @@ def format_capabilities(capabilities_list: List[dict]) -> List[Capability]: ChargeLimit = NamedTuple("ChargeLimit", [("limit", float), ("meta", namedtuple)]) + +class ServiceCost: + total_cost: Optional[float] = None + currency: Optional[str] = None + + +class ServiceDetail: + type: str + value: Union[None, str, float] = None + + +class ServiceTask: + task_id: Optional[str] = None + task_description: Optional[str] = None + + +class ServiceRecord: + odometer_distance: float + service_date: datetime + service_id: Optional[str] = None + service_tasks: List[ServiceTask] + service_details: List[ServiceDetail] + service_cost: ServiceCost + + +ServiceHistory = NamedTuple( + "ServiceHistory", [("items", List[ServiceRecord]), ("meta", namedtuple)] +) + Battery = NamedTuple( "Battery", [("percent_remaining", float), ("range", float), ("meta", namedtuple)], @@ -399,6 +428,9 @@ def select_named_tuple(path: str, response_or_dict) -> NamedTuple: elif path == "charge/limit": return ChargeLimit(data["limit"], headers) + elif path == "service/history": + return ServiceHistory(data, headers) + elif path == "permissions": return Permissions( data["permissions"], diff --git a/smartcar/vehicle.py b/smartcar/vehicle.py index 3e9cfa7a..6d594fe8 100644 --- a/smartcar/vehicle.py +++ b/smartcar/vehicle.py @@ -1,6 +1,6 @@ from collections import namedtuple import json -from typing import Callable, List +from typing import Callable, List, Optional import smartcar.config as config import smartcar.helpers as helpers import smartcar.smartcar @@ -184,6 +184,41 @@ def odometer(self) -> types.Odometer: response = helpers.requester("GET", url, headers=headers) return types.select_named_tuple(path, response) + def service_history( + self, start_date: Optional[str] = None, end_date: Optional[str] = None + ) -> types.ServiceHistory: + """ + Returns a list of all the service records performed on the vehicle, + filtered by the optional date range. If no dates are specified, records from the + last year are returned. + + Args: + start_date (Optional[str]): The start date for the record filter, either in 'YYYY-MM-DD' or + 'YYYY-MM-DDTHH:MM:SS.SSSZ' format. + end_date (Optional[str]): The end date for the record filter, similar format to start_date. + + Returns: + ServiceHistory: NamedTuple("ServiceHistory", [("items", List[ServiceRecord]), ("meta", namedtuple)]) + + Raises: + SmartcarException: If an error occurs during the API call. + + See Also: + Smartcar API Documentation for Vehicle Service History: + https://smartcar.com/docs/api#get-vehicle-service-history + """ + path = "service/history" + url = self._format_url(path) + headers = self._get_headers() + params = {} + if start_date: + params["startDate"] = start_date + if end_date: + params["endDate"] = end_date + + response = helpers.requester("GET", url, headers=headers, params=params) + return types.select_named_tuple(path, response) + def location(self) -> types.Location: """ GET Vehicle.location diff --git a/tests/conftest.py b/tests/conftest.py index 3aff5cca..3030b556 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,7 +158,12 @@ def access_ford(client): client = sc.AuthClient(*ah.get_auth_client_params()) code = ah.run_auth_flow( client.get_auth_url( - ["required:read_charge", "required:control_charge", "control_navigation"] + [ + "required:read_charge", + "required:control_charge", + "control_navigation", + "read_service_history", + ] ), "FORD", ) diff --git a/tests/e2e/test_vehicle.py b/tests/e2e/test_vehicle.py index 7ddf2dcd..4fb5a3b0 100644 --- a/tests/e2e/test_vehicle.py +++ b/tests/e2e/test_vehicle.py @@ -147,6 +147,29 @@ def test_send_destination(ford_car): assert response._fields == ("status", "message", "meta") +def test_service_history(ford_car): + response = ford_car.service_history("2023-05-20", "2024-02-10") + assert isinstance( + response, types.ServiceHistory + ), "Response should be an instance of ServiceHistory" + assert hasattr(response, "_fields"), "Response should have '_fields' attribute" + assert "items" in response._fields, "'items' should be a key in the response fields" + + # Check the 'items' array. + assert isinstance(response.items, list), "Items should be a list" + + # Iterate over each item in the 'items' list to perform further validations. + for item in response.items: + assert isinstance( + item["odometerDistance"], (float, int) + ), "Odometer distance should be a numeric type (float or int)" + assert ( + item["odometerDistance"] > 0 + ), "Odometer distance should be greater than zero" + + assert response._fields == ("items", "meta") + + def test_batch_success(chevy_volt): batch = chevy_volt.batch( [