pytoyoda.models.vehicle

Vehicle model.

  1"""Vehicle model."""
  2
  3# ruff: noqa: FA100
  4
  5import asyncio
  6import copy
  7import json
  8from dataclasses import dataclass
  9from datetime import date, timedelta
 10from enum import Enum, auto
 11from functools import partial
 12from itertools import groupby
 13from operator import attrgetter
 14from typing import Any, Callable, Optional, TypeVar, Union
 15
 16from arrow import Arrow
 17from loguru import logger
 18from pydantic import computed_field
 19
 20from pytoyoda.api import Api
 21from pytoyoda.exceptions import ToyotaApiError
 22from pytoyoda.models.climate import ClimateSettings, ClimateStatus
 23from pytoyoda.models.dashboard import Dashboard
 24from pytoyoda.models.electric_status import ElectricStatus
 25from pytoyoda.models.endpoints.command import CommandType
 26from pytoyoda.models.endpoints.common import StatusModel
 27from pytoyoda.models.endpoints.trips import _SummaryItemModel
 28from pytoyoda.models.endpoints.vehicle_guid import VehicleGuidModel
 29from pytoyoda.models.location import Location
 30from pytoyoda.models.lock_status import LockStatus
 31from pytoyoda.models.nofication import Notification
 32from pytoyoda.models.service_history import ServiceHistory
 33from pytoyoda.models.summary import Summary, SummaryType
 34from pytoyoda.models.trips import Trip
 35from pytoyoda.utils.helpers import add_with_none
 36from pytoyoda.utils.log_utils import censor_all
 37from pytoyoda.utils.models import CustomAPIBaseModel
 38
 39T = TypeVar(
 40    "T",
 41    bound=Union[Api, VehicleGuidModel, bool],
 42)
 43
 44
 45class VehicleType(Enum):
 46    """Vehicle types."""
 47
 48    PLUG_IN_HYBRID = auto()
 49    FULL_HYBRID = auto()
 50    ELECTRIC = auto()
 51    FUEL_ONLY = auto()
 52
 53    @classmethod
 54    def from_vehicle_info(cls, info: VehicleGuidModel) -> "VehicleType":
 55        """Determine the vehicle type based on detailed vehicle fuel information.
 56
 57        Args:
 58            info (VehicleGuidModel): Vehicle information model
 59
 60        Returns:
 61            VehicleType: Determined vehicle type
 62
 63        """
 64        try:
 65            if info.fuel_type == "B":
 66                vehicle_type = cls.FULL_HYBRID
 67            elif info.fuel_type == "E":
 68                vehicle_type = cls.ELECTRIC
 69            elif info.fuel_type == "I":
 70                vehicle_type = cls.PLUG_IN_HYBRID
 71            else:
 72                vehicle_type = cls.FUEL_ONLY
 73        except AttributeError:
 74            return cls.FUEL_ONLY
 75        else:
 76            return vehicle_type
 77
 78
 79@dataclass
 80class EndpointDefinition:
 81    """Definition of an API endpoint."""
 82
 83    name: str
 84    capable: bool
 85    function: Callable
 86
 87
 88class Vehicle(CustomAPIBaseModel[type[T]]):
 89    """Vehicle data representation."""
 90
 91    def __init__(
 92        self,
 93        api: Api,
 94        vehicle_info: VehicleGuidModel,
 95        metric: bool = True,  # noqa: FBT001, FBT002
 96        **kwargs: dict,
 97    ) -> None:
 98        """Initialise the Vehicle data representation."""
 99        data = {
100            "api": api,
101            "vehicle_info": vehicle_info,
102            "metric": metric,
103        }
104        super().__init__(data=data, **kwargs)  # type: ignore[reportArgumentType, arg-type]
105        self._api = api
106        self._vehicle_info = vehicle_info
107        self._metric = metric
108        self._endpoint_data: dict[str, Any] = {}
109
110        if self._vehicle_info.vin:
111            self._api_endpoints: list[EndpointDefinition] = [
112                EndpointDefinition(
113                    name="location",
114                    capable=(
115                        getattr(
116                            getattr(self._vehicle_info, "extended_capabilities", False),
117                            "last_parked_capable",
118                            False,
119                        )
120                        or getattr(
121                            getattr(self._vehicle_info, "features", False),
122                            "last_parked",
123                            False,
124                        )
125                    ),
126                    function=partial(
127                        self._api.get_location, vin=self._vehicle_info.vin
128                    ),
129                ),
130                EndpointDefinition(
131                    name="health_status",
132                    capable=True,
133                    function=partial(
134                        self._api.get_vehicle_health_status,
135                        vin=self._vehicle_info.vin,
136                    ),
137                ),
138                EndpointDefinition(
139                    name="electric_status",
140                    capable=getattr(
141                        getattr(self._vehicle_info, "extended_capabilities", False),
142                        "econnect_vehicle_status_capable",
143                        False,
144                    ),
145                    function=partial(
146                        self._api.get_vehicle_electric_status,
147                        vin=self._vehicle_info.vin,
148                    ),
149                ),
150                EndpointDefinition(
151                    name="telemetry",
152                    capable=getattr(
153                        getattr(self._vehicle_info, "extended_capabilities", False),
154                        "telemetry_capable",
155                        False,
156                    ),
157                    function=partial(
158                        self._api.get_telemetry, vin=self._vehicle_info.vin
159                    ),
160                ),
161                EndpointDefinition(
162                    name="notifications",
163                    capable=True,
164                    function=partial(
165                        self._api.get_notifications, vin=self._vehicle_info.vin
166                    ),
167                ),
168                EndpointDefinition(
169                    name="status",
170                    capable=getattr(
171                        getattr(self._vehicle_info, "extended_capabilities", False),
172                        "vehicle_status",
173                        False,
174                    ),
175                    function=partial(
176                        self._api.get_remote_status, vin=self._vehicle_info.vin
177                    ),
178                ),
179                EndpointDefinition(
180                    name="service_history",
181                    capable=getattr(
182                        getattr(self._vehicle_info, "features", False),
183                        "service_history",
184                        False,
185                    ),
186                    function=partial(
187                        self._api.get_service_history, vin=self._vehicle_info.vin
188                    ),
189                ),
190                EndpointDefinition(
191                    name="climate_settings",
192                    capable=getattr(
193                        getattr(self._vehicle_info, "features", False),
194                        "climate_start_engine",
195                        False,
196                    ),
197                    function=partial(
198                        self._api.get_climate_settings, vin=self._vehicle_info.vin
199                    ),
200                ),
201                EndpointDefinition(
202                    name="climate_status",
203                    capable=getattr(
204                        getattr(self._vehicle_info, "features", False),
205                        "climate_start_engine",
206                        False,
207                    ),
208                    function=partial(
209                        self._api.get_climate_status, vin=self._vehicle_info.vin
210                    ),
211                ),
212                EndpointDefinition(
213                    name="trip_history",
214                    capable=True,
215                    function=partial(
216                        self._api.get_trips,
217                        vin=self._vehicle_info.vin,
218                        from_date=(date.today() - timedelta(days=90)),  # noqa: DTZ011
219                        to_date=date.today(),  # noqa: DTZ011
220                        summary=True,
221                        limit=1,
222                        offset=0,
223                        route=False,
224                    ),
225                ),
226            ]
227        else:
228            raise ToyotaApiError(
229                logger.error(
230                    "The VIN (vehicle identification number) "
231                    "required for the end point request could not be determined"
232                )
233            )
234        self._endpoint_collect = [
235            (endpoint.name, endpoint.function)
236            for endpoint in self._api_endpoints
237            if endpoint.capable
238        ]
239
240    async def update(self) -> None:
241        """Update the data for the vehicle.
242
243        This method asynchronously updates the data for the vehicle by
244        calling the endpoint functions in parallel.
245
246        Returns:
247            None
248
249        """
250
251        async def parallel_wrapper(
252            name: str, function: partial
253        ) -> tuple[str, dict[str, Any]]:
254            r = await function()
255            return name, r
256
257        responses = asyncio.gather(
258            *[
259                parallel_wrapper(name, function)
260                for name, function in self._endpoint_collect
261            ]
262        )
263        for name, data in await responses:
264            self._endpoint_data[name] = data
265
266    @computed_field  # type: ignore[prop-decorator]
267    @property
268    def vin(self) -> Optional[str]:
269        """Return the vehicles VIN number.
270
271        Returns:
272            Optional[str]: The vehicles VIN number
273
274        """
275        return self._vehicle_info.vin
276
277    @computed_field  # type: ignore[prop-decorator]
278    @property
279    def alias(self) -> Optional[str]:
280        """Vehicle's alias.
281
282        Returns:
283            Optional[str]: Nickname of vehicle
284
285        """
286        return self._vehicle_info.nickname
287
288    @computed_field  # type: ignore[prop-decorator]
289    @property
290    def type(self) -> Optional[str]:
291        """Returns the "type" of vehicle.
292
293        Returns:
294            Optional[str]: "fuel" if only fuel based
295                "mildhybrid" if hybrid
296                "phev" if plugin hybrid
297                "ev" if full electric vehicle
298
299        """
300        vehicle_type = VehicleType.from_vehicle_info(self._vehicle_info)
301        return vehicle_type.name.lower()
302
303    @computed_field  # type: ignore[prop-decorator]
304    @property
305    def dashboard(self) -> Optional[Dashboard]:
306        """Returns the Vehicle dashboard.
307
308        The dashboard consists of items of information you would expect to
309        find on the dashboard. i.e. Fuel Levels.
310
311        Returns:
312            Optional[Dashboard]: A dashboard
313
314        """
315        # Always returns a Dashboard object as we can always get the odometer value
316        return Dashboard(
317            self._endpoint_data.get("telemetry", None),
318            self._endpoint_data.get("electric_status", None),
319            self._endpoint_data.get("health_status", None),
320            self._metric,
321        )
322
323    @computed_field  # type: ignore[prop-decorator]
324    @property
325    def climate_settings(self) -> Optional[ClimateSettings]:
326        """Return the vehicle climate settings.
327
328        Returns:
329            Optional[ClimateSettings]: A climate settings
330
331        """
332        return ClimateSettings(self._endpoint_data.get("climate_settings", None))
333
334    @computed_field  # type: ignore[prop-decorator]
335    @property
336    def climate_status(self) -> Optional[ClimateStatus]:
337        """Return the vehicle climate status.
338
339        Returns:
340            Optional[ClimateStatus]: A climate status
341
342        """
343        return ClimateStatus(self._endpoint_data.get("climate_status", None))
344
345    @computed_field  # type: ignore[prop-decorator]
346    @property
347    def electric_status(self) -> Optional[ElectricStatus]:
348        """Returns the Electric Status of the vehicle.
349
350        Returns:
351            Optional[ElectricStatus]: Electric Status
352
353        """
354        return ElectricStatus(self._endpoint_data.get("electric_status", None))
355
356    async def refresh_electric_realtime_status(self) -> StatusModel:
357        """Force update of electric realtime status.
358
359        This will drain the 12V battery of the vehicle if
360        used to often!
361
362        Returns:
363            StatusModel: A status response for the command.
364
365        """
366        return await self._api.refresh_electric_realtime_status(self.vin)
367
368    @computed_field  # type: ignore[prop-decorator]
369    @property
370    def location(self) -> Optional[Location]:
371        """Return the vehicles latest reported Location.
372
373        Returns:
374            Optional[Location]: The latest location or None. If None vehicle car
375                does not support providing location information.
376                _Note_ an empty location object can be returned when the Vehicle
377                supports location but none is currently available.
378
379        """
380        return Location(self._endpoint_data.get("location", None))
381
382    @computed_field  # type: ignore[prop-decorator]
383    @property
384    def notifications(self) -> Optional[list[Notification]]:
385        r"""Returns a list of notifications for the vehicle.
386
387        Returns:
388            Optional[list[Notification]]: A list of notifications for the vehicle,
389                or None if not supported.
390
391        """
392        if "notifications" in self._endpoint_data:
393            ret: list[Notification] = []
394            for p in self._endpoint_data["notifications"].payload:
395                ret.extend(Notification(n) for n in p.notifications)
396            return ret
397
398        return None
399
400    @computed_field  # type: ignore[prop-decorator]
401    @property
402    def service_history(self) -> Optional[list[ServiceHistory]]:
403        r"""Returns a list of service history entries for the vehicle.
404
405        Returns:
406            Optional[list[ServiceHistory]]: A list of service history entries
407                for the vehicle, or None if not supported.
408
409        """
410        if "service_history" in self._endpoint_data:
411            ret: list[ServiceHistory] = []
412            payload = self._endpoint_data["service_history"].payload
413            if not payload:
414                return None
415            ret.extend(
416                ServiceHistory(service_history)
417                for service_history in payload.service_histories
418            )
419            return ret
420
421        return None
422
423    def get_latest_service_history(self) -> Optional[ServiceHistory]:
424        r"""Return the latest service history entry for the vehicle.
425
426        Returns:
427            Optional[ServiceHistory]: A service history entry for the vehicle,
428                ordered by date and service_category. None if not supported or unknown.
429
430        """
431        if self.service_history is not None:
432            return max(
433                self.service_history, key=lambda x: (x.service_date, x.service_category)
434            )
435        return None
436
437    @computed_field  # type: ignore[prop-decorator]
438    @property
439    def lock_status(self) -> Optional[LockStatus]:
440        """Returns the latest lock status of Doors & Windows.
441
442        Returns:
443            Optional[LockStatus]: The latest lock status of Doors & Windows,
444                or None if not supported.
445
446        """
447        return LockStatus(self._endpoint_data.get("status", None))
448
449    @computed_field  # type: ignore[prop-decorator]
450    @property
451    def last_trip(self) -> Optional[Trip]:
452        """Returns the Vehicle last trip.
453
454        Returns:
455            Optional[Trip]: The last trip
456
457        """
458        ret = None
459        if "trip_history" in self._endpoint_data:
460            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
461
462        return None if ret is None else Trip(ret, self._metric)
463
464    @computed_field  # type: ignore[prop-decorator]
465    @property
466    def trip_history(self) -> Optional[list[Trip]]:
467        """Returns the Vehicle trips.
468
469        Returns:
470            Optional[list[Trip]]: A list of trips
471
472        """
473        if "trip_history" in self._endpoint_data:
474            ret: list[Trip] = []
475            payload = self._endpoint_data["trip_history"].payload
476            ret.extend(Trip(t, self._metric) for t in payload.trips)
477            return ret
478
479        return None
480
481    async def get_summary(
482        self,
483        from_date: date,
484        to_date: date,
485        summary_type: SummaryType = SummaryType.MONTHLY,
486    ) -> list[Summary]:
487        """Return different summarys between the provided dates.
488
489        All but Daily can return a partial time range. For example
490        if the summary_type is weekly and the date ranges selected
491        include partial weeks these partial weeks will be returned.
492        The dates contained in the summary will indicate the range
493        of dates that made up the partial week.
494
495        Note: Weekly and yearly summaries lose a small amount of
496        accuracy due to rounding issues.
497
498        Args:
499            from_date (date, required): The inclusive from date to report summaries.
500            to_date (date, required): The inclusive to date to report summaries.
501            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
502                Monthly by default.
503
504        Returns:
505            list[Summary]: A list of summaries or empty list if not supported.
506
507        """
508        to_date = min(to_date, date.today())  # noqa : DTZ011
509
510        # Summary information is always returned in the first response.
511        # No need to check all the following pages
512        resp = await self._api.get_trips(
513            self.vin, from_date, to_date, summary=True, limit=1, offset=0
514        )
515        if resp.payload is None or len(resp.payload.summary) == 0:
516            return []
517
518        # Convert to response
519        if summary_type == SummaryType.DAILY:
520            return self._generate_daily_summaries(resp.payload.summary)
521        if summary_type == SummaryType.WEEKLY:
522            return self._generate_weekly_summaries(resp.payload.summary)
523        if summary_type == SummaryType.MONTHLY:
524            return self._generate_monthly_summaries(
525                resp.payload.summary, from_date, to_date
526            )
527        if summary_type == SummaryType.YEARLY:
528            return self._generate_yearly_summaries(resp.payload.summary, to_date)
529        msg = "No such SummaryType"
530        raise AssertionError(msg)
531
532    async def get_current_day_summary(self) -> Optional[Summary]:
533        """Return a summary for the current day.
534
535        Returns:
536            Optional[Summary]: A summary or None if not supported.
537
538        """
539        summary = await self.get_summary(
540            from_date=Arrow.now().date(),
541            to_date=Arrow.now().date(),
542            summary_type=SummaryType.DAILY,
543        )
544        min_no_of_summaries_required_for_calculation = 2
545        if len(summary) < min_no_of_summaries_required_for_calculation:
546            logger.info("Not enough summaries for calculation.")
547        return summary[0] if len(summary) > 0 else None
548
549    async def get_current_week_summary(self) -> Optional[Summary]:
550        """Return a summary for the current week.
551
552        Returns:
553            Optional[Summary]: A summary or None if not supported.
554
555        """
556        summary = await self.get_summary(
557            from_date=Arrow.now().floor("week").date(),
558            to_date=Arrow.now().date(),
559            summary_type=SummaryType.WEEKLY,
560        )
561        min_no_of_summaries_required_for_calculation = 2
562        if len(summary) < min_no_of_summaries_required_for_calculation:
563            logger.info("Not enough summaries for calculation.")
564        return summary[0] if len(summary) > 0 else None
565
566    async def get_current_month_summary(self) -> Optional[Summary]:
567        """Return a summary for the current month.
568
569        Returns:
570            Optional[Summary]: A summary or None if not supported.
571
572        """
573        summary = await self.get_summary(
574            from_date=Arrow.now().floor("month").date(),
575            to_date=Arrow.now().date(),
576            summary_type=SummaryType.MONTHLY,
577        )
578        min_no_of_summaries_required_for_calculation = 2
579        if len(summary) < min_no_of_summaries_required_for_calculation:
580            logger.info("Not enough summaries for calculation.")
581        return summary[0] if len(summary) > 0 else None
582
583    async def get_current_year_summary(self) -> Optional[Summary]:
584        """Return a summary for the current year.
585
586        Returns:
587            Optional[Summary]: A summary or None if not supported.
588
589        """
590        summary = await self.get_summary(
591            from_date=Arrow.now().floor("year").date(),
592            to_date=Arrow.now().date(),
593            summary_type=SummaryType.YEARLY,
594        )
595        min_no_of_summaries_required_for_calculation = 2
596        if len(summary) < min_no_of_summaries_required_for_calculation:
597            logger.info("Not enough summaries for calculation.")
598        return summary[0] if len(summary) > 0 else None
599
600    async def get_trips(
601        self,
602        from_date: date,
603        to_date: date,
604        full_route: bool = False,  # noqa : FBT001, FBT002
605    ) -> Optional[list[Trip]]:
606        """Return information on all trips made between the provided dates.
607
608        Args:
609            from_date (date, required): The inclusive from date
610            to_date (date, required): The inclusive to date
611            full_route (bool, optional): Provide the full route
612                                         information for each trip.
613
614        Returns:
615            Optional[list[Trip]]: A list of all trips or None if not supported.
616
617        """
618        ret: list[Trip] = []
619        offset = 0
620        while True:
621            resp = await self._api.get_trips(
622                self.vin,
623                from_date,
624                to_date,
625                summary=False,
626                limit=5,
627                offset=offset,
628                route=full_route,
629            )
630            if resp.payload is None:
631                break
632
633            # Convert to response
634            if resp.payload.trips:
635                ret.extend(Trip(t, self._metric) for t in resp.payload.trips)
636
637            offset = resp.payload.metadata.pagination.next_offset
638            if offset is None:
639                break
640
641        return ret
642
643    async def get_last_trip(self) -> Optional[Trip]:
644        """Return information on the last trip.
645
646        Returns:
647            Optional[Trip]: A trip model or None if not supported.
648
649        """
650        resp = await self._api.get_trips(
651            self.vin,
652            date.today() - timedelta(days=90),  # noqa : DTZ011
653            date.today(),  # noqa : DTZ011
654            summary=False,
655            limit=1,
656            offset=0,
657            route=False,
658        )
659
660        if resp.payload is None:
661            return None
662
663        ret = next(iter(resp.payload.trips), None)
664        return None if ret is None else Trip(ret, self._metric)
665
666    async def refresh_climate_status(self) -> StatusModel:
667        """Force update of climate status.
668
669        Returns:
670            StatusModel: A status response for the command.
671
672        """
673        return await self._api.refresh_climate_status(self.vin)
674
675    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
676        """Send remote command to the vehicle.
677
678        Args:
679            command (CommandType): The remote command model
680            beeps (int): Amount of beeps for commands that support it
681
682        Returns:
683            StatusModel: A status response for the command.
684
685        """
686        return await self._api.send_command(self.vin, command=command, beeps=beeps)
687
688    #
689    # More get functionality depending on what we find
690    #
691
692    async def set_alias(
693        self,
694        value: bool,  # noqa : FBT001
695    ) -> bool:
696        """Set the alias for the vehicle.
697
698        Args:
699            value: The alias value to set for the vehicle.
700
701        Returns:
702            bool: Indicator if value is set
703
704        """
705        return value
706
707    #
708    # More set functionality depending on what we find
709    #
710
711    def _dump_all(self) -> dict[str, Any]:
712        """Dump data from all endpoints for debugging and further work."""
713        dump: [str, Any] = {
714            "vehicle_info": json.loads(self._vehicle_info.model_dump_json())
715        }
716        for name, data in self._endpoint_data.items():
717            dump[name] = json.loads(data.model_dump_json())
718
719        return censor_all(copy.deepcopy(dump))
720
721    def _generate_daily_summaries(
722        self, summary: list[_SummaryItemModel]
723    ) -> list[Summary]:
724        summary.sort(key=attrgetter("year", "month"))
725        return [
726            Summary(
727                histogram.summary,
728                self._metric,
729                Arrow(histogram.year, histogram.month, histogram.day).date(),
730                Arrow(histogram.year, histogram.month, histogram.day).date(),
731                histogram.hdc,
732            )
733            for month in summary
734            for histogram in sorted(month.histograms, key=attrgetter("day"))
735        ]
736
737    def _generate_weekly_summaries(
738        self, summary: list[_SummaryItemModel]
739    ) -> list[Summary]:
740        ret: list[Summary] = []
741        summary.sort(key=attrgetter("year", "month"))
742
743        # Flatten the list of histograms
744        histograms = [histogram for month in summary for histogram in month.histograms]
745        histograms.sort(key=lambda h: date(day=h.day, month=h.month, year=h.year))
746
747        # Group histograms by week
748        for _, week_histograms_iter in groupby(
749            histograms, key=lambda h: Arrow(h.year, h.month, h.day).span("week")[0]
750        ):
751            week_histograms = list(week_histograms_iter)
752            build_hdc = copy.copy(week_histograms[0].hdc)
753            build_summary = copy.copy(week_histograms[0].summary)
754            start_date = Arrow(
755                week_histograms[0].year,
756                week_histograms[0].month,
757                week_histograms[0].day,
758            )
759
760            for histogram in week_histograms[1:]:
761                add_with_none(build_hdc, histogram.hdc)
762                build_summary += histogram.summary
763
764            end_date = Arrow(
765                week_histograms[-1].year,
766                week_histograms[-1].month,
767                week_histograms[-1].day,
768            )
769            ret.append(
770                Summary(
771                    build_summary,
772                    self._metric,
773                    start_date.date(),
774                    end_date.date(),
775                    build_hdc,
776                )
777            )
778
779        return ret
780
781    def _generate_monthly_summaries(
782        self, summary: list[_SummaryItemModel], from_date: date, to_date: date
783    ) -> list[Summary]:
784        # Convert all the monthly responses from the payload to a summary response
785        ret: list[Summary] = []
786        summary.sort(key=attrgetter("year", "month"))
787        for month in summary:
788            month_start = Arrow(month.year, month.month, 1).date()
789            month_end = (
790                Arrow(month.year, month.month, 1).shift(months=1).shift(days=-1).date()
791            )
792
793            ret.append(
794                Summary(
795                    month.summary,
796                    self._metric,
797                    # The data might not include an entire month
798                    # so update start and end dates.
799                    max(month_start, from_date),
800                    min(month_end, to_date),
801                    month.hdc,
802                )
803            )
804
805        return ret
806
807    def _generate_yearly_summaries(
808        self, summary: list[_SummaryItemModel], to_date: date
809    ) -> list[Summary]:
810        summary.sort(key=attrgetter("year", "month"))
811        ret: list[Summary] = []
812        build_hdc = copy.copy(summary[0].hdc)
813        build_summary = copy.copy(summary[0].summary)
814        start_date = date(day=1, month=summary[0].month, year=summary[0].year)
815
816        if len(summary) == 1:
817            ret.append(
818                Summary(build_summary, self._metric, start_date, to_date, build_hdc)
819            )
820        else:
821            for month, next_month in zip(
822                summary[1:], [*summary[2:], None], strict=False
823            ):
824                summary_month = date(day=1, month=month.month, year=month.year)
825                add_with_none(build_hdc, month.hdc)
826                build_summary += month.summary
827
828                if next_month is None or next_month.year != month.year:
829                    end_date = min(
830                        to_date, date(day=31, month=12, year=summary_month.year)
831                    )
832                    ret.append(
833                        Summary(
834                            build_summary, self._metric, start_date, end_date, build_hdc
835                        )
836                    )
837                    if next_month:
838                        start_date = date(
839                            day=1, month=next_month.month, year=next_month.year
840                        )
841                        build_hdc = copy.copy(next_month.hdc)
842                        build_summary = copy.copy(next_month.summary)
843
844        return ret
class VehicleType(enum.Enum):
46class VehicleType(Enum):
47    """Vehicle types."""
48
49    PLUG_IN_HYBRID = auto()
50    FULL_HYBRID = auto()
51    ELECTRIC = auto()
52    FUEL_ONLY = auto()
53
54    @classmethod
55    def from_vehicle_info(cls, info: VehicleGuidModel) -> "VehicleType":
56        """Determine the vehicle type based on detailed vehicle fuel information.
57
58        Args:
59            info (VehicleGuidModel): Vehicle information model
60
61        Returns:
62            VehicleType: Determined vehicle type
63
64        """
65        try:
66            if info.fuel_type == "B":
67                vehicle_type = cls.FULL_HYBRID
68            elif info.fuel_type == "E":
69                vehicle_type = cls.ELECTRIC
70            elif info.fuel_type == "I":
71                vehicle_type = cls.PLUG_IN_HYBRID
72            else:
73                vehicle_type = cls.FUEL_ONLY
74        except AttributeError:
75            return cls.FUEL_ONLY
76        else:
77            return vehicle_type

Vehicle types.

PLUG_IN_HYBRID = <VehicleType.PLUG_IN_HYBRID: 1>
FULL_HYBRID = <VehicleType.FULL_HYBRID: 2>
ELECTRIC = <VehicleType.ELECTRIC: 3>
FUEL_ONLY = <VehicleType.FUEL_ONLY: 4>
@classmethod
def from_vehicle_info( cls, info: pytoyoda.models.endpoints.vehicle_guid.VehicleGuidModel) -> VehicleType:
54    @classmethod
55    def from_vehicle_info(cls, info: VehicleGuidModel) -> "VehicleType":
56        """Determine the vehicle type based on detailed vehicle fuel information.
57
58        Args:
59            info (VehicleGuidModel): Vehicle information model
60
61        Returns:
62            VehicleType: Determined vehicle type
63
64        """
65        try:
66            if info.fuel_type == "B":
67                vehicle_type = cls.FULL_HYBRID
68            elif info.fuel_type == "E":
69                vehicle_type = cls.ELECTRIC
70            elif info.fuel_type == "I":
71                vehicle_type = cls.PLUG_IN_HYBRID
72            else:
73                vehicle_type = cls.FUEL_ONLY
74        except AttributeError:
75            return cls.FUEL_ONLY
76        else:
77            return vehicle_type

Determine the vehicle type based on detailed vehicle fuel information.

Arguments:
  • info (VehicleGuidModel): Vehicle information model
Returns:

VehicleType: Determined vehicle type

@dataclass
class EndpointDefinition:
80@dataclass
81class EndpointDefinition:
82    """Definition of an API endpoint."""
83
84    name: str
85    capable: bool
86    function: Callable

Definition of an API endpoint.

EndpointDefinition(name: str, capable: bool, function: Callable)
name: str
capable: bool
function: Callable
class Vehicle(pydantic.main.BaseModel, typing.Generic[~T]):
 89class Vehicle(CustomAPIBaseModel[type[T]]):
 90    """Vehicle data representation."""
 91
 92    def __init__(
 93        self,
 94        api: Api,
 95        vehicle_info: VehicleGuidModel,
 96        metric: bool = True,  # noqa: FBT001, FBT002
 97        **kwargs: dict,
 98    ) -> None:
 99        """Initialise the Vehicle data representation."""
100        data = {
101            "api": api,
102            "vehicle_info": vehicle_info,
103            "metric": metric,
104        }
105        super().__init__(data=data, **kwargs)  # type: ignore[reportArgumentType, arg-type]
106        self._api = api
107        self._vehicle_info = vehicle_info
108        self._metric = metric
109        self._endpoint_data: dict[str, Any] = {}
110
111        if self._vehicle_info.vin:
112            self._api_endpoints: list[EndpointDefinition] = [
113                EndpointDefinition(
114                    name="location",
115                    capable=(
116                        getattr(
117                            getattr(self._vehicle_info, "extended_capabilities", False),
118                            "last_parked_capable",
119                            False,
120                        )
121                        or getattr(
122                            getattr(self._vehicle_info, "features", False),
123                            "last_parked",
124                            False,
125                        )
126                    ),
127                    function=partial(
128                        self._api.get_location, vin=self._vehicle_info.vin
129                    ),
130                ),
131                EndpointDefinition(
132                    name="health_status",
133                    capable=True,
134                    function=partial(
135                        self._api.get_vehicle_health_status,
136                        vin=self._vehicle_info.vin,
137                    ),
138                ),
139                EndpointDefinition(
140                    name="electric_status",
141                    capable=getattr(
142                        getattr(self._vehicle_info, "extended_capabilities", False),
143                        "econnect_vehicle_status_capable",
144                        False,
145                    ),
146                    function=partial(
147                        self._api.get_vehicle_electric_status,
148                        vin=self._vehicle_info.vin,
149                    ),
150                ),
151                EndpointDefinition(
152                    name="telemetry",
153                    capable=getattr(
154                        getattr(self._vehicle_info, "extended_capabilities", False),
155                        "telemetry_capable",
156                        False,
157                    ),
158                    function=partial(
159                        self._api.get_telemetry, vin=self._vehicle_info.vin
160                    ),
161                ),
162                EndpointDefinition(
163                    name="notifications",
164                    capable=True,
165                    function=partial(
166                        self._api.get_notifications, vin=self._vehicle_info.vin
167                    ),
168                ),
169                EndpointDefinition(
170                    name="status",
171                    capable=getattr(
172                        getattr(self._vehicle_info, "extended_capabilities", False),
173                        "vehicle_status",
174                        False,
175                    ),
176                    function=partial(
177                        self._api.get_remote_status, vin=self._vehicle_info.vin
178                    ),
179                ),
180                EndpointDefinition(
181                    name="service_history",
182                    capable=getattr(
183                        getattr(self._vehicle_info, "features", False),
184                        "service_history",
185                        False,
186                    ),
187                    function=partial(
188                        self._api.get_service_history, vin=self._vehicle_info.vin
189                    ),
190                ),
191                EndpointDefinition(
192                    name="climate_settings",
193                    capable=getattr(
194                        getattr(self._vehicle_info, "features", False),
195                        "climate_start_engine",
196                        False,
197                    ),
198                    function=partial(
199                        self._api.get_climate_settings, vin=self._vehicle_info.vin
200                    ),
201                ),
202                EndpointDefinition(
203                    name="climate_status",
204                    capable=getattr(
205                        getattr(self._vehicle_info, "features", False),
206                        "climate_start_engine",
207                        False,
208                    ),
209                    function=partial(
210                        self._api.get_climate_status, vin=self._vehicle_info.vin
211                    ),
212                ),
213                EndpointDefinition(
214                    name="trip_history",
215                    capable=True,
216                    function=partial(
217                        self._api.get_trips,
218                        vin=self._vehicle_info.vin,
219                        from_date=(date.today() - timedelta(days=90)),  # noqa: DTZ011
220                        to_date=date.today(),  # noqa: DTZ011
221                        summary=True,
222                        limit=1,
223                        offset=0,
224                        route=False,
225                    ),
226                ),
227            ]
228        else:
229            raise ToyotaApiError(
230                logger.error(
231                    "The VIN (vehicle identification number) "
232                    "required for the end point request could not be determined"
233                )
234            )
235        self._endpoint_collect = [
236            (endpoint.name, endpoint.function)
237            for endpoint in self._api_endpoints
238            if endpoint.capable
239        ]
240
241    async def update(self) -> None:
242        """Update the data for the vehicle.
243
244        This method asynchronously updates the data for the vehicle by
245        calling the endpoint functions in parallel.
246
247        Returns:
248            None
249
250        """
251
252        async def parallel_wrapper(
253            name: str, function: partial
254        ) -> tuple[str, dict[str, Any]]:
255            r = await function()
256            return name, r
257
258        responses = asyncio.gather(
259            *[
260                parallel_wrapper(name, function)
261                for name, function in self._endpoint_collect
262            ]
263        )
264        for name, data in await responses:
265            self._endpoint_data[name] = data
266
267    @computed_field  # type: ignore[prop-decorator]
268    @property
269    def vin(self) -> Optional[str]:
270        """Return the vehicles VIN number.
271
272        Returns:
273            Optional[str]: The vehicles VIN number
274
275        """
276        return self._vehicle_info.vin
277
278    @computed_field  # type: ignore[prop-decorator]
279    @property
280    def alias(self) -> Optional[str]:
281        """Vehicle's alias.
282
283        Returns:
284            Optional[str]: Nickname of vehicle
285
286        """
287        return self._vehicle_info.nickname
288
289    @computed_field  # type: ignore[prop-decorator]
290    @property
291    def type(self) -> Optional[str]:
292        """Returns the "type" of vehicle.
293
294        Returns:
295            Optional[str]: "fuel" if only fuel based
296                "mildhybrid" if hybrid
297                "phev" if plugin hybrid
298                "ev" if full electric vehicle
299
300        """
301        vehicle_type = VehicleType.from_vehicle_info(self._vehicle_info)
302        return vehicle_type.name.lower()
303
304    @computed_field  # type: ignore[prop-decorator]
305    @property
306    def dashboard(self) -> Optional[Dashboard]:
307        """Returns the Vehicle dashboard.
308
309        The dashboard consists of items of information you would expect to
310        find on the dashboard. i.e. Fuel Levels.
311
312        Returns:
313            Optional[Dashboard]: A dashboard
314
315        """
316        # Always returns a Dashboard object as we can always get the odometer value
317        return Dashboard(
318            self._endpoint_data.get("telemetry", None),
319            self._endpoint_data.get("electric_status", None),
320            self._endpoint_data.get("health_status", None),
321            self._metric,
322        )
323
324    @computed_field  # type: ignore[prop-decorator]
325    @property
326    def climate_settings(self) -> Optional[ClimateSettings]:
327        """Return the vehicle climate settings.
328
329        Returns:
330            Optional[ClimateSettings]: A climate settings
331
332        """
333        return ClimateSettings(self._endpoint_data.get("climate_settings", None))
334
335    @computed_field  # type: ignore[prop-decorator]
336    @property
337    def climate_status(self) -> Optional[ClimateStatus]:
338        """Return the vehicle climate status.
339
340        Returns:
341            Optional[ClimateStatus]: A climate status
342
343        """
344        return ClimateStatus(self._endpoint_data.get("climate_status", None))
345
346    @computed_field  # type: ignore[prop-decorator]
347    @property
348    def electric_status(self) -> Optional[ElectricStatus]:
349        """Returns the Electric Status of the vehicle.
350
351        Returns:
352            Optional[ElectricStatus]: Electric Status
353
354        """
355        return ElectricStatus(self._endpoint_data.get("electric_status", None))
356
357    async def refresh_electric_realtime_status(self) -> StatusModel:
358        """Force update of electric realtime status.
359
360        This will drain the 12V battery of the vehicle if
361        used to often!
362
363        Returns:
364            StatusModel: A status response for the command.
365
366        """
367        return await self._api.refresh_electric_realtime_status(self.vin)
368
369    @computed_field  # type: ignore[prop-decorator]
370    @property
371    def location(self) -> Optional[Location]:
372        """Return the vehicles latest reported Location.
373
374        Returns:
375            Optional[Location]: The latest location or None. If None vehicle car
376                does not support providing location information.
377                _Note_ an empty location object can be returned when the Vehicle
378                supports location but none is currently available.
379
380        """
381        return Location(self._endpoint_data.get("location", None))
382
383    @computed_field  # type: ignore[prop-decorator]
384    @property
385    def notifications(self) -> Optional[list[Notification]]:
386        r"""Returns a list of notifications for the vehicle.
387
388        Returns:
389            Optional[list[Notification]]: A list of notifications for the vehicle,
390                or None if not supported.
391
392        """
393        if "notifications" in self._endpoint_data:
394            ret: list[Notification] = []
395            for p in self._endpoint_data["notifications"].payload:
396                ret.extend(Notification(n) for n in p.notifications)
397            return ret
398
399        return None
400
401    @computed_field  # type: ignore[prop-decorator]
402    @property
403    def service_history(self) -> Optional[list[ServiceHistory]]:
404        r"""Returns a list of service history entries for the vehicle.
405
406        Returns:
407            Optional[list[ServiceHistory]]: A list of service history entries
408                for the vehicle, or None if not supported.
409
410        """
411        if "service_history" in self._endpoint_data:
412            ret: list[ServiceHistory] = []
413            payload = self._endpoint_data["service_history"].payload
414            if not payload:
415                return None
416            ret.extend(
417                ServiceHistory(service_history)
418                for service_history in payload.service_histories
419            )
420            return ret
421
422        return None
423
424    def get_latest_service_history(self) -> Optional[ServiceHistory]:
425        r"""Return the latest service history entry for the vehicle.
426
427        Returns:
428            Optional[ServiceHistory]: A service history entry for the vehicle,
429                ordered by date and service_category. None if not supported or unknown.
430
431        """
432        if self.service_history is not None:
433            return max(
434                self.service_history, key=lambda x: (x.service_date, x.service_category)
435            )
436        return None
437
438    @computed_field  # type: ignore[prop-decorator]
439    @property
440    def lock_status(self) -> Optional[LockStatus]:
441        """Returns the latest lock status of Doors & Windows.
442
443        Returns:
444            Optional[LockStatus]: The latest lock status of Doors & Windows,
445                or None if not supported.
446
447        """
448        return LockStatus(self._endpoint_data.get("status", None))
449
450    @computed_field  # type: ignore[prop-decorator]
451    @property
452    def last_trip(self) -> Optional[Trip]:
453        """Returns the Vehicle last trip.
454
455        Returns:
456            Optional[Trip]: The last trip
457
458        """
459        ret = None
460        if "trip_history" in self._endpoint_data:
461            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
462
463        return None if ret is None else Trip(ret, self._metric)
464
465    @computed_field  # type: ignore[prop-decorator]
466    @property
467    def trip_history(self) -> Optional[list[Trip]]:
468        """Returns the Vehicle trips.
469
470        Returns:
471            Optional[list[Trip]]: A list of trips
472
473        """
474        if "trip_history" in self._endpoint_data:
475            ret: list[Trip] = []
476            payload = self._endpoint_data["trip_history"].payload
477            ret.extend(Trip(t, self._metric) for t in payload.trips)
478            return ret
479
480        return None
481
482    async def get_summary(
483        self,
484        from_date: date,
485        to_date: date,
486        summary_type: SummaryType = SummaryType.MONTHLY,
487    ) -> list[Summary]:
488        """Return different summarys between the provided dates.
489
490        All but Daily can return a partial time range. For example
491        if the summary_type is weekly and the date ranges selected
492        include partial weeks these partial weeks will be returned.
493        The dates contained in the summary will indicate the range
494        of dates that made up the partial week.
495
496        Note: Weekly and yearly summaries lose a small amount of
497        accuracy due to rounding issues.
498
499        Args:
500            from_date (date, required): The inclusive from date to report summaries.
501            to_date (date, required): The inclusive to date to report summaries.
502            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
503                Monthly by default.
504
505        Returns:
506            list[Summary]: A list of summaries or empty list if not supported.
507
508        """
509        to_date = min(to_date, date.today())  # noqa : DTZ011
510
511        # Summary information is always returned in the first response.
512        # No need to check all the following pages
513        resp = await self._api.get_trips(
514            self.vin, from_date, to_date, summary=True, limit=1, offset=0
515        )
516        if resp.payload is None or len(resp.payload.summary) == 0:
517            return []
518
519        # Convert to response
520        if summary_type == SummaryType.DAILY:
521            return self._generate_daily_summaries(resp.payload.summary)
522        if summary_type == SummaryType.WEEKLY:
523            return self._generate_weekly_summaries(resp.payload.summary)
524        if summary_type == SummaryType.MONTHLY:
525            return self._generate_monthly_summaries(
526                resp.payload.summary, from_date, to_date
527            )
528        if summary_type == SummaryType.YEARLY:
529            return self._generate_yearly_summaries(resp.payload.summary, to_date)
530        msg = "No such SummaryType"
531        raise AssertionError(msg)
532
533    async def get_current_day_summary(self) -> Optional[Summary]:
534        """Return a summary for the current day.
535
536        Returns:
537            Optional[Summary]: A summary or None if not supported.
538
539        """
540        summary = await self.get_summary(
541            from_date=Arrow.now().date(),
542            to_date=Arrow.now().date(),
543            summary_type=SummaryType.DAILY,
544        )
545        min_no_of_summaries_required_for_calculation = 2
546        if len(summary) < min_no_of_summaries_required_for_calculation:
547            logger.info("Not enough summaries for calculation.")
548        return summary[0] if len(summary) > 0 else None
549
550    async def get_current_week_summary(self) -> Optional[Summary]:
551        """Return a summary for the current week.
552
553        Returns:
554            Optional[Summary]: A summary or None if not supported.
555
556        """
557        summary = await self.get_summary(
558            from_date=Arrow.now().floor("week").date(),
559            to_date=Arrow.now().date(),
560            summary_type=SummaryType.WEEKLY,
561        )
562        min_no_of_summaries_required_for_calculation = 2
563        if len(summary) < min_no_of_summaries_required_for_calculation:
564            logger.info("Not enough summaries for calculation.")
565        return summary[0] if len(summary) > 0 else None
566
567    async def get_current_month_summary(self) -> Optional[Summary]:
568        """Return a summary for the current month.
569
570        Returns:
571            Optional[Summary]: A summary or None if not supported.
572
573        """
574        summary = await self.get_summary(
575            from_date=Arrow.now().floor("month").date(),
576            to_date=Arrow.now().date(),
577            summary_type=SummaryType.MONTHLY,
578        )
579        min_no_of_summaries_required_for_calculation = 2
580        if len(summary) < min_no_of_summaries_required_for_calculation:
581            logger.info("Not enough summaries for calculation.")
582        return summary[0] if len(summary) > 0 else None
583
584    async def get_current_year_summary(self) -> Optional[Summary]:
585        """Return a summary for the current year.
586
587        Returns:
588            Optional[Summary]: A summary or None if not supported.
589
590        """
591        summary = await self.get_summary(
592            from_date=Arrow.now().floor("year").date(),
593            to_date=Arrow.now().date(),
594            summary_type=SummaryType.YEARLY,
595        )
596        min_no_of_summaries_required_for_calculation = 2
597        if len(summary) < min_no_of_summaries_required_for_calculation:
598            logger.info("Not enough summaries for calculation.")
599        return summary[0] if len(summary) > 0 else None
600
601    async def get_trips(
602        self,
603        from_date: date,
604        to_date: date,
605        full_route: bool = False,  # noqa : FBT001, FBT002
606    ) -> Optional[list[Trip]]:
607        """Return information on all trips made between the provided dates.
608
609        Args:
610            from_date (date, required): The inclusive from date
611            to_date (date, required): The inclusive to date
612            full_route (bool, optional): Provide the full route
613                                         information for each trip.
614
615        Returns:
616            Optional[list[Trip]]: A list of all trips or None if not supported.
617
618        """
619        ret: list[Trip] = []
620        offset = 0
621        while True:
622            resp = await self._api.get_trips(
623                self.vin,
624                from_date,
625                to_date,
626                summary=False,
627                limit=5,
628                offset=offset,
629                route=full_route,
630            )
631            if resp.payload is None:
632                break
633
634            # Convert to response
635            if resp.payload.trips:
636                ret.extend(Trip(t, self._metric) for t in resp.payload.trips)
637
638            offset = resp.payload.metadata.pagination.next_offset
639            if offset is None:
640                break
641
642        return ret
643
644    async def get_last_trip(self) -> Optional[Trip]:
645        """Return information on the last trip.
646
647        Returns:
648            Optional[Trip]: A trip model or None if not supported.
649
650        """
651        resp = await self._api.get_trips(
652            self.vin,
653            date.today() - timedelta(days=90),  # noqa : DTZ011
654            date.today(),  # noqa : DTZ011
655            summary=False,
656            limit=1,
657            offset=0,
658            route=False,
659        )
660
661        if resp.payload is None:
662            return None
663
664        ret = next(iter(resp.payload.trips), None)
665        return None if ret is None else Trip(ret, self._metric)
666
667    async def refresh_climate_status(self) -> StatusModel:
668        """Force update of climate status.
669
670        Returns:
671            StatusModel: A status response for the command.
672
673        """
674        return await self._api.refresh_climate_status(self.vin)
675
676    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
677        """Send remote command to the vehicle.
678
679        Args:
680            command (CommandType): The remote command model
681            beeps (int): Amount of beeps for commands that support it
682
683        Returns:
684            StatusModel: A status response for the command.
685
686        """
687        return await self._api.send_command(self.vin, command=command, beeps=beeps)
688
689    #
690    # More get functionality depending on what we find
691    #
692
693    async def set_alias(
694        self,
695        value: bool,  # noqa : FBT001
696    ) -> bool:
697        """Set the alias for the vehicle.
698
699        Args:
700            value: The alias value to set for the vehicle.
701
702        Returns:
703            bool: Indicator if value is set
704
705        """
706        return value
707
708    #
709    # More set functionality depending on what we find
710    #
711
712    def _dump_all(self) -> dict[str, Any]:
713        """Dump data from all endpoints for debugging and further work."""
714        dump: [str, Any] = {
715            "vehicle_info": json.loads(self._vehicle_info.model_dump_json())
716        }
717        for name, data in self._endpoint_data.items():
718            dump[name] = json.loads(data.model_dump_json())
719
720        return censor_all(copy.deepcopy(dump))
721
722    def _generate_daily_summaries(
723        self, summary: list[_SummaryItemModel]
724    ) -> list[Summary]:
725        summary.sort(key=attrgetter("year", "month"))
726        return [
727            Summary(
728                histogram.summary,
729                self._metric,
730                Arrow(histogram.year, histogram.month, histogram.day).date(),
731                Arrow(histogram.year, histogram.month, histogram.day).date(),
732                histogram.hdc,
733            )
734            for month in summary
735            for histogram in sorted(month.histograms, key=attrgetter("day"))
736        ]
737
738    def _generate_weekly_summaries(
739        self, summary: list[_SummaryItemModel]
740    ) -> list[Summary]:
741        ret: list[Summary] = []
742        summary.sort(key=attrgetter("year", "month"))
743
744        # Flatten the list of histograms
745        histograms = [histogram for month in summary for histogram in month.histograms]
746        histograms.sort(key=lambda h: date(day=h.day, month=h.month, year=h.year))
747
748        # Group histograms by week
749        for _, week_histograms_iter in groupby(
750            histograms, key=lambda h: Arrow(h.year, h.month, h.day).span("week")[0]
751        ):
752            week_histograms = list(week_histograms_iter)
753            build_hdc = copy.copy(week_histograms[0].hdc)
754            build_summary = copy.copy(week_histograms[0].summary)
755            start_date = Arrow(
756                week_histograms[0].year,
757                week_histograms[0].month,
758                week_histograms[0].day,
759            )
760
761            for histogram in week_histograms[1:]:
762                add_with_none(build_hdc, histogram.hdc)
763                build_summary += histogram.summary
764
765            end_date = Arrow(
766                week_histograms[-1].year,
767                week_histograms[-1].month,
768                week_histograms[-1].day,
769            )
770            ret.append(
771                Summary(
772                    build_summary,
773                    self._metric,
774                    start_date.date(),
775                    end_date.date(),
776                    build_hdc,
777                )
778            )
779
780        return ret
781
782    def _generate_monthly_summaries(
783        self, summary: list[_SummaryItemModel], from_date: date, to_date: date
784    ) -> list[Summary]:
785        # Convert all the monthly responses from the payload to a summary response
786        ret: list[Summary] = []
787        summary.sort(key=attrgetter("year", "month"))
788        for month in summary:
789            month_start = Arrow(month.year, month.month, 1).date()
790            month_end = (
791                Arrow(month.year, month.month, 1).shift(months=1).shift(days=-1).date()
792            )
793
794            ret.append(
795                Summary(
796                    month.summary,
797                    self._metric,
798                    # The data might not include an entire month
799                    # so update start and end dates.
800                    max(month_start, from_date),
801                    min(month_end, to_date),
802                    month.hdc,
803                )
804            )
805
806        return ret
807
808    def _generate_yearly_summaries(
809        self, summary: list[_SummaryItemModel], to_date: date
810    ) -> list[Summary]:
811        summary.sort(key=attrgetter("year", "month"))
812        ret: list[Summary] = []
813        build_hdc = copy.copy(summary[0].hdc)
814        build_summary = copy.copy(summary[0].summary)
815        start_date = date(day=1, month=summary[0].month, year=summary[0].year)
816
817        if len(summary) == 1:
818            ret.append(
819                Summary(build_summary, self._metric, start_date, to_date, build_hdc)
820            )
821        else:
822            for month, next_month in zip(
823                summary[1:], [*summary[2:], None], strict=False
824            ):
825                summary_month = date(day=1, month=month.month, year=month.year)
826                add_with_none(build_hdc, month.hdc)
827                build_summary += month.summary
828
829                if next_month is None or next_month.year != month.year:
830                    end_date = min(
831                        to_date, date(day=31, month=12, year=summary_month.year)
832                    )
833                    ret.append(
834                        Summary(
835                            build_summary, self._metric, start_date, end_date, build_hdc
836                        )
837                    )
838                    if next_month:
839                        start_date = date(
840                            day=1, month=next_month.month, year=next_month.year
841                        )
842                        build_hdc = copy.copy(next_month.hdc)
843                        build_summary = copy.copy(next_month.summary)
844
845        return ret

Vehicle data representation.

async def update(self) -> None:
241    async def update(self) -> None:
242        """Update the data for the vehicle.
243
244        This method asynchronously updates the data for the vehicle by
245        calling the endpoint functions in parallel.
246
247        Returns:
248            None
249
250        """
251
252        async def parallel_wrapper(
253            name: str, function: partial
254        ) -> tuple[str, dict[str, Any]]:
255            r = await function()
256            return name, r
257
258        responses = asyncio.gather(
259            *[
260                parallel_wrapper(name, function)
261                for name, function in self._endpoint_collect
262            ]
263        )
264        for name, data in await responses:
265            self._endpoint_data[name] = data

Update the data for the vehicle.

This method asynchronously updates the data for the vehicle by calling the endpoint functions in parallel.

Returns:

None

vin: Optional[str]
267    @computed_field  # type: ignore[prop-decorator]
268    @property
269    def vin(self) -> Optional[str]:
270        """Return the vehicles VIN number.
271
272        Returns:
273            Optional[str]: The vehicles VIN number
274
275        """
276        return self._vehicle_info.vin

Return the vehicles VIN number.

Returns:

Optional[str]: The vehicles VIN number

alias: Optional[str]
278    @computed_field  # type: ignore[prop-decorator]
279    @property
280    def alias(self) -> Optional[str]:
281        """Vehicle's alias.
282
283        Returns:
284            Optional[str]: Nickname of vehicle
285
286        """
287        return self._vehicle_info.nickname

Vehicle's alias.

Returns:

Optional[str]: Nickname of vehicle

type: Optional[str]
289    @computed_field  # type: ignore[prop-decorator]
290    @property
291    def type(self) -> Optional[str]:
292        """Returns the "type" of vehicle.
293
294        Returns:
295            Optional[str]: "fuel" if only fuel based
296                "mildhybrid" if hybrid
297                "phev" if plugin hybrid
298                "ev" if full electric vehicle
299
300        """
301        vehicle_type = VehicleType.from_vehicle_info(self._vehicle_info)
302        return vehicle_type.name.lower()

Returns the "type" of vehicle.

Returns:

Optional[str]: "fuel" if only fuel based "mildhybrid" if hybrid "phev" if plugin hybrid "ev" if full electric vehicle

dashboard: Optional[pytoyoda.models.dashboard.Dashboard]
304    @computed_field  # type: ignore[prop-decorator]
305    @property
306    def dashboard(self) -> Optional[Dashboard]:
307        """Returns the Vehicle dashboard.
308
309        The dashboard consists of items of information you would expect to
310        find on the dashboard. i.e. Fuel Levels.
311
312        Returns:
313            Optional[Dashboard]: A dashboard
314
315        """
316        # Always returns a Dashboard object as we can always get the odometer value
317        return Dashboard(
318            self._endpoint_data.get("telemetry", None),
319            self._endpoint_data.get("electric_status", None),
320            self._endpoint_data.get("health_status", None),
321            self._metric,
322        )

Returns the Vehicle dashboard.

The dashboard consists of items of information you would expect to find on the dashboard. i.e. Fuel Levels.

Returns:

Optional[Dashboard]: A dashboard

climate_settings: Optional[pytoyoda.models.climate.ClimateSettings]
324    @computed_field  # type: ignore[prop-decorator]
325    @property
326    def climate_settings(self) -> Optional[ClimateSettings]:
327        """Return the vehicle climate settings.
328
329        Returns:
330            Optional[ClimateSettings]: A climate settings
331
332        """
333        return ClimateSettings(self._endpoint_data.get("climate_settings", None))

Return the vehicle climate settings.

Returns:

Optional[ClimateSettings]: A climate settings

climate_status: Optional[pytoyoda.models.climate.ClimateStatus]
335    @computed_field  # type: ignore[prop-decorator]
336    @property
337    def climate_status(self) -> Optional[ClimateStatus]:
338        """Return the vehicle climate status.
339
340        Returns:
341            Optional[ClimateStatus]: A climate status
342
343        """
344        return ClimateStatus(self._endpoint_data.get("climate_status", None))

Return the vehicle climate status.

Returns:

Optional[ClimateStatus]: A climate status

electric_status: Optional[pytoyoda.models.electric_status.ElectricStatus]
346    @computed_field  # type: ignore[prop-decorator]
347    @property
348    def electric_status(self) -> Optional[ElectricStatus]:
349        """Returns the Electric Status of the vehicle.
350
351        Returns:
352            Optional[ElectricStatus]: Electric Status
353
354        """
355        return ElectricStatus(self._endpoint_data.get("electric_status", None))

Returns the Electric Status of the vehicle.

Returns:

Optional[ElectricStatus]: Electric Status

async def refresh_electric_realtime_status(self) -> pytoyoda.models.endpoints.common.StatusModel:
357    async def refresh_electric_realtime_status(self) -> StatusModel:
358        """Force update of electric realtime status.
359
360        This will drain the 12V battery of the vehicle if
361        used to often!
362
363        Returns:
364            StatusModel: A status response for the command.
365
366        """
367        return await self._api.refresh_electric_realtime_status(self.vin)

Force update of electric realtime status.

This will drain the 12V battery of the vehicle if used to often!

Returns:

StatusModel: A status response for the command.

location: Optional[pytoyoda.models.location.Location]
369    @computed_field  # type: ignore[prop-decorator]
370    @property
371    def location(self) -> Optional[Location]:
372        """Return the vehicles latest reported Location.
373
374        Returns:
375            Optional[Location]: The latest location or None. If None vehicle car
376                does not support providing location information.
377                _Note_ an empty location object can be returned when the Vehicle
378                supports location but none is currently available.
379
380        """
381        return Location(self._endpoint_data.get("location", None))

Return the vehicles latest reported Location.

Returns:

Optional[Location]: The latest location or None. If None vehicle car does not support providing location information. _Note_ an empty location object can be returned when the Vehicle supports location but none is currently available.

notifications: Optional[list[pytoyoda.models.nofication.Notification]]
383    @computed_field  # type: ignore[prop-decorator]
384    @property
385    def notifications(self) -> Optional[list[Notification]]:
386        r"""Returns a list of notifications for the vehicle.
387
388        Returns:
389            Optional[list[Notification]]: A list of notifications for the vehicle,
390                or None if not supported.
391
392        """
393        if "notifications" in self._endpoint_data:
394            ret: list[Notification] = []
395            for p in self._endpoint_data["notifications"].payload:
396                ret.extend(Notification(n) for n in p.notifications)
397            return ret
398
399        return None

Returns a list of notifications for the vehicle.

Returns:

Optional[list[Notification]]: A list of notifications for the vehicle, or None if not supported.

service_history: Optional[list[pytoyoda.models.service_history.ServiceHistory]]
401    @computed_field  # type: ignore[prop-decorator]
402    @property
403    def service_history(self) -> Optional[list[ServiceHistory]]:
404        r"""Returns a list of service history entries for the vehicle.
405
406        Returns:
407            Optional[list[ServiceHistory]]: A list of service history entries
408                for the vehicle, or None if not supported.
409
410        """
411        if "service_history" in self._endpoint_data:
412            ret: list[ServiceHistory] = []
413            payload = self._endpoint_data["service_history"].payload
414            if not payload:
415                return None
416            ret.extend(
417                ServiceHistory(service_history)
418                for service_history in payload.service_histories
419            )
420            return ret
421
422        return None

Returns a list of service history entries for the vehicle.

Returns:

Optional[list[ServiceHistory]]: A list of service history entries for the vehicle, or None if not supported.

def get_latest_service_history(self) -> Optional[pytoyoda.models.service_history.ServiceHistory]:
424    def get_latest_service_history(self) -> Optional[ServiceHistory]:
425        r"""Return the latest service history entry for the vehicle.
426
427        Returns:
428            Optional[ServiceHistory]: A service history entry for the vehicle,
429                ordered by date and service_category. None if not supported or unknown.
430
431        """
432        if self.service_history is not None:
433            return max(
434                self.service_history, key=lambda x: (x.service_date, x.service_category)
435            )
436        return None

Return the latest service history entry for the vehicle.

Returns:

Optional[ServiceHistory]: A service history entry for the vehicle, ordered by date and service_category. None if not supported or unknown.

lock_status: Optional[pytoyoda.models.lock_status.LockStatus]
438    @computed_field  # type: ignore[prop-decorator]
439    @property
440    def lock_status(self) -> Optional[LockStatus]:
441        """Returns the latest lock status of Doors & Windows.
442
443        Returns:
444            Optional[LockStatus]: The latest lock status of Doors & Windows,
445                or None if not supported.
446
447        """
448        return LockStatus(self._endpoint_data.get("status", None))

Returns the latest lock status of Doors & Windows.

Returns:

Optional[LockStatus]: The latest lock status of Doors & Windows, or None if not supported.

last_trip: Optional[pytoyoda.models.trips.Trip]
450    @computed_field  # type: ignore[prop-decorator]
451    @property
452    def last_trip(self) -> Optional[Trip]:
453        """Returns the Vehicle last trip.
454
455        Returns:
456            Optional[Trip]: The last trip
457
458        """
459        ret = None
460        if "trip_history" in self._endpoint_data:
461            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
462
463        return None if ret is None else Trip(ret, self._metric)

Returns the Vehicle last trip.

Returns:

Optional[Trip]: The last trip

trip_history: Optional[list[pytoyoda.models.trips.Trip]]
465    @computed_field  # type: ignore[prop-decorator]
466    @property
467    def trip_history(self) -> Optional[list[Trip]]:
468        """Returns the Vehicle trips.
469
470        Returns:
471            Optional[list[Trip]]: A list of trips
472
473        """
474        if "trip_history" in self._endpoint_data:
475            ret: list[Trip] = []
476            payload = self._endpoint_data["trip_history"].payload
477            ret.extend(Trip(t, self._metric) for t in payload.trips)
478            return ret
479
480        return None

Returns the Vehicle trips.

Returns:

Optional[list[Trip]]: A list of trips

async def get_summary( self, from_date: datetime.date, to_date: datetime.date, summary_type: pytoyoda.models.summary.SummaryType = <SummaryType.MONTHLY: 3>) -> list[pytoyoda.models.summary.Summary]:
482    async def get_summary(
483        self,
484        from_date: date,
485        to_date: date,
486        summary_type: SummaryType = SummaryType.MONTHLY,
487    ) -> list[Summary]:
488        """Return different summarys between the provided dates.
489
490        All but Daily can return a partial time range. For example
491        if the summary_type is weekly and the date ranges selected
492        include partial weeks these partial weeks will be returned.
493        The dates contained in the summary will indicate the range
494        of dates that made up the partial week.
495
496        Note: Weekly and yearly summaries lose a small amount of
497        accuracy due to rounding issues.
498
499        Args:
500            from_date (date, required): The inclusive from date to report summaries.
501            to_date (date, required): The inclusive to date to report summaries.
502            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
503                Monthly by default.
504
505        Returns:
506            list[Summary]: A list of summaries or empty list if not supported.
507
508        """
509        to_date = min(to_date, date.today())  # noqa : DTZ011
510
511        # Summary information is always returned in the first response.
512        # No need to check all the following pages
513        resp = await self._api.get_trips(
514            self.vin, from_date, to_date, summary=True, limit=1, offset=0
515        )
516        if resp.payload is None or len(resp.payload.summary) == 0:
517            return []
518
519        # Convert to response
520        if summary_type == SummaryType.DAILY:
521            return self._generate_daily_summaries(resp.payload.summary)
522        if summary_type == SummaryType.WEEKLY:
523            return self._generate_weekly_summaries(resp.payload.summary)
524        if summary_type == SummaryType.MONTHLY:
525            return self._generate_monthly_summaries(
526                resp.payload.summary, from_date, to_date
527            )
528        if summary_type == SummaryType.YEARLY:
529            return self._generate_yearly_summaries(resp.payload.summary, to_date)
530        msg = "No such SummaryType"
531        raise AssertionError(msg)

Return different summarys between the provided dates.

All but Daily can return a partial time range. For example if the summary_type is weekly and the date ranges selected include partial weeks these partial weeks will be returned. The dates contained in the summary will indicate the range of dates that made up the partial week.

Note: Weekly and yearly summaries lose a small amount of accuracy due to rounding issues.

Arguments:
  • from_date (date, required): The inclusive from date to report summaries.
  • to_date (date, required): The inclusive to date to report summaries.
  • summary_type (SummaryType, optional): Daily, Monthly or Yearly summary. Monthly by default.
Returns:

list[Summary]: A list of summaries or empty list if not supported.

async def get_current_day_summary(self) -> Optional[pytoyoda.models.summary.Summary]:
533    async def get_current_day_summary(self) -> Optional[Summary]:
534        """Return a summary for the current day.
535
536        Returns:
537            Optional[Summary]: A summary or None if not supported.
538
539        """
540        summary = await self.get_summary(
541            from_date=Arrow.now().date(),
542            to_date=Arrow.now().date(),
543            summary_type=SummaryType.DAILY,
544        )
545        min_no_of_summaries_required_for_calculation = 2
546        if len(summary) < min_no_of_summaries_required_for_calculation:
547            logger.info("Not enough summaries for calculation.")
548        return summary[0] if len(summary) > 0 else None

Return a summary for the current day.

Returns:

Optional[Summary]: A summary or None if not supported.

async def get_current_week_summary(self) -> Optional[pytoyoda.models.summary.Summary]:
550    async def get_current_week_summary(self) -> Optional[Summary]:
551        """Return a summary for the current week.
552
553        Returns:
554            Optional[Summary]: A summary or None if not supported.
555
556        """
557        summary = await self.get_summary(
558            from_date=Arrow.now().floor("week").date(),
559            to_date=Arrow.now().date(),
560            summary_type=SummaryType.WEEKLY,
561        )
562        min_no_of_summaries_required_for_calculation = 2
563        if len(summary) < min_no_of_summaries_required_for_calculation:
564            logger.info("Not enough summaries for calculation.")
565        return summary[0] if len(summary) > 0 else None

Return a summary for the current week.

Returns:

Optional[Summary]: A summary or None if not supported.

async def get_current_month_summary(self) -> Optional[pytoyoda.models.summary.Summary]:
567    async def get_current_month_summary(self) -> Optional[Summary]:
568        """Return a summary for the current month.
569
570        Returns:
571            Optional[Summary]: A summary or None if not supported.
572
573        """
574        summary = await self.get_summary(
575            from_date=Arrow.now().floor("month").date(),
576            to_date=Arrow.now().date(),
577            summary_type=SummaryType.MONTHLY,
578        )
579        min_no_of_summaries_required_for_calculation = 2
580        if len(summary) < min_no_of_summaries_required_for_calculation:
581            logger.info("Not enough summaries for calculation.")
582        return summary[0] if len(summary) > 0 else None

Return a summary for the current month.

Returns:

Optional[Summary]: A summary or None if not supported.

async def get_current_year_summary(self) -> Optional[pytoyoda.models.summary.Summary]:
584    async def get_current_year_summary(self) -> Optional[Summary]:
585        """Return a summary for the current year.
586
587        Returns:
588            Optional[Summary]: A summary or None if not supported.
589
590        """
591        summary = await self.get_summary(
592            from_date=Arrow.now().floor("year").date(),
593            to_date=Arrow.now().date(),
594            summary_type=SummaryType.YEARLY,
595        )
596        min_no_of_summaries_required_for_calculation = 2
597        if len(summary) < min_no_of_summaries_required_for_calculation:
598            logger.info("Not enough summaries for calculation.")
599        return summary[0] if len(summary) > 0 else None

Return a summary for the current year.

Returns:

Optional[Summary]: A summary or None if not supported.

async def get_trips( self, from_date: datetime.date, to_date: datetime.date, full_route: bool = False) -> Optional[list[pytoyoda.models.trips.Trip]]:
601    async def get_trips(
602        self,
603        from_date: date,
604        to_date: date,
605        full_route: bool = False,  # noqa : FBT001, FBT002
606    ) -> Optional[list[Trip]]:
607        """Return information on all trips made between the provided dates.
608
609        Args:
610            from_date (date, required): The inclusive from date
611            to_date (date, required): The inclusive to date
612            full_route (bool, optional): Provide the full route
613                                         information for each trip.
614
615        Returns:
616            Optional[list[Trip]]: A list of all trips or None if not supported.
617
618        """
619        ret: list[Trip] = []
620        offset = 0
621        while True:
622            resp = await self._api.get_trips(
623                self.vin,
624                from_date,
625                to_date,
626                summary=False,
627                limit=5,
628                offset=offset,
629                route=full_route,
630            )
631            if resp.payload is None:
632                break
633
634            # Convert to response
635            if resp.payload.trips:
636                ret.extend(Trip(t, self._metric) for t in resp.payload.trips)
637
638            offset = resp.payload.metadata.pagination.next_offset
639            if offset is None:
640                break
641
642        return ret

Return information on all trips made between the provided dates.

Arguments:
  • from_date (date, required): The inclusive from date
  • to_date (date, required): The inclusive to date
  • full_route (bool, optional): Provide the full route information for each trip.
Returns:

Optional[list[Trip]]: A list of all trips or None if not supported.

async def get_last_trip(self) -> Optional[pytoyoda.models.trips.Trip]:
644    async def get_last_trip(self) -> Optional[Trip]:
645        """Return information on the last trip.
646
647        Returns:
648            Optional[Trip]: A trip model or None if not supported.
649
650        """
651        resp = await self._api.get_trips(
652            self.vin,
653            date.today() - timedelta(days=90),  # noqa : DTZ011
654            date.today(),  # noqa : DTZ011
655            summary=False,
656            limit=1,
657            offset=0,
658            route=False,
659        )
660
661        if resp.payload is None:
662            return None
663
664        ret = next(iter(resp.payload.trips), None)
665        return None if ret is None else Trip(ret, self._metric)

Return information on the last trip.

Returns:

Optional[Trip]: A trip model or None if not supported.

async def refresh_climate_status(self) -> pytoyoda.models.endpoints.common.StatusModel:
667    async def refresh_climate_status(self) -> StatusModel:
668        """Force update of climate status.
669
670        Returns:
671            StatusModel: A status response for the command.
672
673        """
674        return await self._api.refresh_climate_status(self.vin)

Force update of climate status.

Returns:

StatusModel: A status response for the command.

async def post_command( self, command: pytoyoda.models.endpoints.command.CommandType, beeps: int = 0) -> pytoyoda.models.endpoints.common.StatusModel:
676    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
677        """Send remote command to the vehicle.
678
679        Args:
680            command (CommandType): The remote command model
681            beeps (int): Amount of beeps for commands that support it
682
683        Returns:
684            StatusModel: A status response for the command.
685
686        """
687        return await self._api.send_command(self.vin, command=command, beeps=beeps)

Send remote command to the vehicle.

Arguments:
  • command (CommandType): The remote command model
  • beeps (int): Amount of beeps for commands that support it
Returns:

StatusModel: A status response for the command.

async def set_alias(self, value: bool) -> bool:
693    async def set_alias(
694        self,
695        value: bool,  # noqa : FBT001
696    ) -> bool:
697        """Set the alias for the vehicle.
698
699        Args:
700            value: The alias value to set for the vehicle.
701
702        Returns:
703            bool: Indicator if value is set
704
705        """
706        return value

Set the alias for the vehicle.

Arguments:
  • value: The alias value to set for the vehicle.
Returns:

bool: Indicator if value is set