pytoyoda.models.vehicle

Vehicle model.

  1"""Vehicle model."""
  2
  3# ruff: noqa : FA100, UP007
  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            ret.extend(
414                ServiceHistory(service_history)
415                for service_history in payload.service_histories
416            )
417            return ret
418
419        return None
420
421    def get_latest_service_history(self) -> Optional[ServiceHistory]:
422        r"""Return the latest service history entry for the vehicle.
423
424        Returns:
425            Optional[ServiceHistory]: A service history entry for the vehicle,
426                ordered by date and service_category. None if not supported or unknown.
427
428        """
429        if self.service_history is not None:
430            return max(
431                self.service_history, key=lambda x: (x.service_date, x.service_category)
432            )
433        return None
434
435    @computed_field  # type: ignore[prop-decorator]
436    @property
437    def lock_status(self) -> Optional[LockStatus]:
438        """Returns the latest lock status of Doors & Windows.
439
440        Returns:
441            Optional[LockStatus]: The latest lock status of Doors & Windows,
442                or None if not supported.
443
444        """
445        return LockStatus(self._endpoint_data.get("status", None))
446
447    @computed_field  # type: ignore[prop-decorator]
448    @property
449    def last_trip(self) -> Optional[Trip]:
450        """Returns the Vehicle last trip.
451
452        Returns:
453            Optional[Trip]: The last trip
454
455        """
456        ret = None
457        if "trip_history" in self._endpoint_data:
458            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
459
460        return None if ret is None else Trip(ret, self._metric)
461
462    @computed_field  # type: ignore[prop-decorator]
463    @property
464    def trip_history(self) -> Optional[list[Trip]]:
465        """Returns the Vehicle trips.
466
467        Returns:
468            Optional[list[Trip]]: A list of trips
469
470        """
471        if "trip_history" in self._endpoint_data:
472            ret: list[Trip] = []
473            payload = self._endpoint_data["trip_history"].payload
474            ret.extend(Trip(t, self._metric) for t in payload.trips)
475            return ret
476
477        return None
478
479    async def get_summary(
480        self,
481        from_date: date,
482        to_date: date,
483        summary_type: SummaryType = SummaryType.MONTHLY,
484    ) -> list[Summary]:
485        """Return different summarys between the provided dates.
486
487        All but Daily can return a partial time range. For example
488        if the summary_type is weekly and the date ranges selected
489        include partial weeks these partial weeks will be returned.
490        The dates contained in the summary will indicate the range
491        of dates that made up the partial week.
492
493        Note: Weekly and yearly summaries lose a small amount of
494        accuracy due to rounding issues.
495
496        Args:
497            from_date (date, required): The inclusive from date to report summaries.
498            to_date (date, required): The inclusive to date to report summaries.
499            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
500                Monthly by default.
501
502        Returns:
503            list[Summary]: A list of summaries or empty list if not supported.
504
505        """
506        to_date = min(to_date, date.today())  # noqa : DTZ011
507
508        # Summary information is always returned in the first response.
509        # No need to check all the following pages
510        resp = await self._api.get_trips(
511            self.vin, from_date, to_date, summary=True, limit=1, offset=0
512        )
513        if resp.payload is None or len(resp.payload.summary) == 0:
514            return []
515
516        # Convert to response
517        if summary_type == SummaryType.DAILY:
518            return self._generate_daily_summaries(resp.payload.summary)
519        if summary_type == SummaryType.WEEKLY:
520            return self._generate_weekly_summaries(resp.payload.summary)
521        if summary_type == SummaryType.MONTHLY:
522            return self._generate_monthly_summaries(
523                resp.payload.summary, from_date, to_date
524            )
525        if summary_type == SummaryType.YEARLY:
526            return self._generate_yearly_summaries(resp.payload.summary, to_date)
527        msg = "No such SummaryType"
528        raise AssertionError(msg)
529
530    async def get_current_day_summary(self) -> Optional[Summary]:
531        """Return a summary for the current day.
532
533        Returns:
534            Optional[Summary]: A summary or None if not supported.
535
536        """
537        summary = await self.get_summary(
538            from_date=Arrow.now().date(),
539            to_date=Arrow.now().date(),
540            summary_type=SummaryType.DAILY,
541        )
542        min_no_of_summaries_required_for_calculation = 2
543        if len(summary) < min_no_of_summaries_required_for_calculation:
544            logger.info("Not enough summaries for calculation.")
545        return summary[0] if len(summary) > 0 else None
546
547    async def get_current_week_summary(self) -> Optional[Summary]:
548        """Return a summary for the current week.
549
550        Returns:
551            Optional[Summary]: A summary or None if not supported.
552
553        """
554        summary = await self.get_summary(
555            from_date=Arrow.now().floor("week").date(),
556            to_date=Arrow.now().date(),
557            summary_type=SummaryType.WEEKLY,
558        )
559        min_no_of_summaries_required_for_calculation = 2
560        if len(summary) < min_no_of_summaries_required_for_calculation:
561            logger.info("Not enough summaries for calculation.")
562        return summary[0] if len(summary) > 0 else None
563
564    async def get_current_month_summary(self) -> Optional[Summary]:
565        """Return a summary for the current month.
566
567        Returns:
568            Optional[Summary]: A summary or None if not supported.
569
570        """
571        summary = await self.get_summary(
572            from_date=Arrow.now().floor("month").date(),
573            to_date=Arrow.now().date(),
574            summary_type=SummaryType.MONTHLY,
575        )
576        min_no_of_summaries_required_for_calculation = 2
577        if len(summary) < min_no_of_summaries_required_for_calculation:
578            logger.info("Not enough summaries for calculation.")
579        return summary[0] if len(summary) > 0 else None
580
581    async def get_current_year_summary(self) -> Optional[Summary]:
582        """Return a summary for the current year.
583
584        Returns:
585            Optional[Summary]: A summary or None if not supported.
586
587        """
588        summary = await self.get_summary(
589            from_date=Arrow.now().floor("year").date(),
590            to_date=Arrow.now().date(),
591            summary_type=SummaryType.YEARLY,
592        )
593        min_no_of_summaries_required_for_calculation = 2
594        if len(summary) < min_no_of_summaries_required_for_calculation:
595            logger.info("Not enough summaries for calculation.")
596        return summary[0] if len(summary) > 0 else None
597
598    async def get_trips(
599        self,
600        from_date: date,
601        to_date: date,
602        full_route: bool = False,  # noqa : FBT001, FBT002
603    ) -> Optional[list[Trip]]:
604        """Return information on all trips made between the provided dates.
605
606        Args:
607            from_date (date, required): The inclusive from date
608            to_date (date, required): The inclusive to date
609            full_route (bool, optional): Provide the full route
610                                         information for each trip.
611
612        Returns:
613            Optional[list[Trip]]: A list of all trips or None if not supported.
614
615        """
616        ret: list[Trip] = []
617        offset = 0
618        while True:
619            resp = await self._api.get_trips(
620                self.vin,
621                from_date,
622                to_date,
623                summary=False,
624                limit=5,
625                offset=offset,
626                route=full_route,
627            )
628            if resp.payload is None:
629                break
630
631            # Convert to response
632            if resp.payload.trips:
633                ret.extend(Trip(t, self._metric) for t in resp.payload.trips)
634
635            offset = resp.payload.metadata.pagination.next_offset
636            if offset is None:
637                break
638
639        return ret
640
641    async def get_last_trip(self) -> Optional[Trip]:
642        """Return information on the last trip.
643
644        Returns:
645            Optional[Trip]: A trip model or None if not supported.
646
647        """
648        resp = await self._api.get_trips(
649            self.vin,
650            date.today() - timedelta(days=90),  # noqa : DTZ011
651            date.today(),  # noqa : DTZ011
652            summary=False,
653            limit=1,
654            offset=0,
655            route=False,
656        )
657
658        if resp.payload is None:
659            return None
660
661        ret = next(iter(resp.payload.trips), None)
662        return None if ret is None else Trip(ret, self._metric)
663
664    async def refresh_climate_status(self) -> StatusModel:
665        """Force update of climate status.
666
667        Returns:
668            StatusModel: A status response for the command.
669
670        """
671        return await self._api.refresh_climate_status(self.vin)
672
673    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
674        """Send remote command to the vehicle.
675
676        Args:
677            command (CommandType): The remote command model
678            beeps (int): Amount of beeps for commands that support it
679
680        Returns:
681            StatusModel: A status response for the command.
682
683        """
684        return await self._api.send_command(self.vin, command=command, beeps=beeps)
685
686    #
687    # More get functionality depending on what we find
688    #
689
690    async def set_alias(
691        self,
692        value: bool,  # noqa : FBT001
693    ) -> bool:
694        """Set the alias for the vehicle.
695
696        Args:
697            value: The alias value to set for the vehicle.
698
699        Returns:
700            bool: Indicator if value is set
701
702        """
703        return value
704
705    #
706    # More set functionality depending on what we find
707    #
708
709    def _dump_all(self) -> dict[str, Any]:
710        """Dump data from all endpoints for debugging and further work."""
711        dump: [str, Any] = {
712            "vehicle_info": json.loads(self._vehicle_info.model_dump_json())
713        }
714        for name, data in self._endpoint_data.items():
715            dump[name] = json.loads(data.model_dump_json())
716
717        return censor_all(copy.deepcopy(dump))
718
719    def _generate_daily_summaries(
720        self, summary: list[_SummaryItemModel]
721    ) -> list[Summary]:
722        summary.sort(key=attrgetter("year", "month"))
723        return [
724            Summary(
725                histogram.summary,
726                self._metric,
727                Arrow(histogram.year, histogram.month, histogram.day).date(),
728                Arrow(histogram.year, histogram.month, histogram.day).date(),
729                histogram.hdc,
730            )
731            for month in summary
732            for histogram in sorted(month.histograms, key=attrgetter("day"))
733        ]
734
735    def _generate_weekly_summaries(
736        self, summary: list[_SummaryItemModel]
737    ) -> list[Summary]:
738        ret: list[Summary] = []
739        summary.sort(key=attrgetter("year", "month"))
740
741        # Flatten the list of histograms
742        histograms = [histogram for month in summary for histogram in month.histograms]
743        histograms.sort(key=lambda h: date(day=h.day, month=h.month, year=h.year))
744
745        # Group histograms by week
746        for _, week_histograms_iter in groupby(
747            histograms, key=lambda h: Arrow(h.year, h.month, h.day).span("week")[0]
748        ):
749            week_histograms = list(week_histograms_iter)
750            build_hdc = copy.copy(week_histograms[0].hdc)
751            build_summary = copy.copy(week_histograms[0].summary)
752            start_date = Arrow(
753                week_histograms[0].year,
754                week_histograms[0].month,
755                week_histograms[0].day,
756            )
757
758            for histogram in week_histograms[1:]:
759                add_with_none(build_hdc, histogram.hdc)
760                build_summary += histogram.summary
761
762            end_date = Arrow(
763                week_histograms[-1].year,
764                week_histograms[-1].month,
765                week_histograms[-1].day,
766            )
767            ret.append(
768                Summary(
769                    build_summary,
770                    self._metric,
771                    start_date.date(),
772                    end_date.date(),
773                    build_hdc,
774                )
775            )
776
777        return ret
778
779    def _generate_monthly_summaries(
780        self, summary: list[_SummaryItemModel], from_date: date, to_date: date
781    ) -> list[Summary]:
782        # Convert all the monthly responses from the payload to a summary response
783        ret: list[Summary] = []
784        summary.sort(key=attrgetter("year", "month"))
785        for month in summary:
786            month_start = Arrow(month.year, month.month, 1).date()
787            month_end = (
788                Arrow(month.year, month.month, 1).shift(months=1).shift(days=-1).date()
789            )
790
791            ret.append(
792                Summary(
793                    month.summary,
794                    self._metric,
795                    # The data might not include an entire month
796                    # so update start and end dates.
797                    max(month_start, from_date),
798                    min(month_end, to_date),
799                    month.hdc,
800                )
801            )
802
803        return ret
804
805    def _generate_yearly_summaries(
806        self, summary: list[_SummaryItemModel], to_date: date
807    ) -> list[Summary]:
808        summary.sort(key=attrgetter("year", "month"))
809        ret: list[Summary] = []
810        build_hdc = copy.copy(summary[0].hdc)
811        build_summary = copy.copy(summary[0].summary)
812        start_date = date(day=1, month=summary[0].month, year=summary[0].year)
813
814        if len(summary) == 1:
815            ret.append(
816                Summary(build_summary, self._metric, start_date, to_date, build_hdc)
817            )
818        else:
819            for month, next_month in zip(
820                summary[1:], summary[2:] + [None], strict=False
821            ):
822                summary_month = date(day=1, month=month.month, year=month.year)
823                add_with_none(build_hdc, month.hdc)
824                build_summary += month.summary
825
826                if next_month is None or next_month.year != month.year:
827                    end_date = min(
828                        to_date, date(day=31, month=12, year=summary_month.year)
829                    )
830                    ret.append(
831                        Summary(
832                            build_summary, self._metric, start_date, end_date, build_hdc
833                        )
834                    )
835                    if next_month:
836                        start_date = date(
837                            day=1, month=next_month.month, year=next_month.year
838                        )
839                        build_hdc = copy.copy(next_month.hdc)
840                        build_summary = copy.copy(next_month.summary)
841
842        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            ret.extend(
415                ServiceHistory(service_history)
416                for service_history in payload.service_histories
417            )
418            return ret
419
420        return None
421
422    def get_latest_service_history(self) -> Optional[ServiceHistory]:
423        r"""Return the latest service history entry for the vehicle.
424
425        Returns:
426            Optional[ServiceHistory]: A service history entry for the vehicle,
427                ordered by date and service_category. None if not supported or unknown.
428
429        """
430        if self.service_history is not None:
431            return max(
432                self.service_history, key=lambda x: (x.service_date, x.service_category)
433            )
434        return None
435
436    @computed_field  # type: ignore[prop-decorator]
437    @property
438    def lock_status(self) -> Optional[LockStatus]:
439        """Returns the latest lock status of Doors & Windows.
440
441        Returns:
442            Optional[LockStatus]: The latest lock status of Doors & Windows,
443                or None if not supported.
444
445        """
446        return LockStatus(self._endpoint_data.get("status", None))
447
448    @computed_field  # type: ignore[prop-decorator]
449    @property
450    def last_trip(self) -> Optional[Trip]:
451        """Returns the Vehicle last trip.
452
453        Returns:
454            Optional[Trip]: The last trip
455
456        """
457        ret = None
458        if "trip_history" in self._endpoint_data:
459            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
460
461        return None if ret is None else Trip(ret, self._metric)
462
463    @computed_field  # type: ignore[prop-decorator]
464    @property
465    def trip_history(self) -> Optional[list[Trip]]:
466        """Returns the Vehicle trips.
467
468        Returns:
469            Optional[list[Trip]]: A list of trips
470
471        """
472        if "trip_history" in self._endpoint_data:
473            ret: list[Trip] = []
474            payload = self._endpoint_data["trip_history"].payload
475            ret.extend(Trip(t, self._metric) for t in payload.trips)
476            return ret
477
478        return None
479
480    async def get_summary(
481        self,
482        from_date: date,
483        to_date: date,
484        summary_type: SummaryType = SummaryType.MONTHLY,
485    ) -> list[Summary]:
486        """Return different summarys between the provided dates.
487
488        All but Daily can return a partial time range. For example
489        if the summary_type is weekly and the date ranges selected
490        include partial weeks these partial weeks will be returned.
491        The dates contained in the summary will indicate the range
492        of dates that made up the partial week.
493
494        Note: Weekly and yearly summaries lose a small amount of
495        accuracy due to rounding issues.
496
497        Args:
498            from_date (date, required): The inclusive from date to report summaries.
499            to_date (date, required): The inclusive to date to report summaries.
500            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
501                Monthly by default.
502
503        Returns:
504            list[Summary]: A list of summaries or empty list if not supported.
505
506        """
507        to_date = min(to_date, date.today())  # noqa : DTZ011
508
509        # Summary information is always returned in the first response.
510        # No need to check all the following pages
511        resp = await self._api.get_trips(
512            self.vin, from_date, to_date, summary=True, limit=1, offset=0
513        )
514        if resp.payload is None or len(resp.payload.summary) == 0:
515            return []
516
517        # Convert to response
518        if summary_type == SummaryType.DAILY:
519            return self._generate_daily_summaries(resp.payload.summary)
520        if summary_type == SummaryType.WEEKLY:
521            return self._generate_weekly_summaries(resp.payload.summary)
522        if summary_type == SummaryType.MONTHLY:
523            return self._generate_monthly_summaries(
524                resp.payload.summary, from_date, to_date
525            )
526        if summary_type == SummaryType.YEARLY:
527            return self._generate_yearly_summaries(resp.payload.summary, to_date)
528        msg = "No such SummaryType"
529        raise AssertionError(msg)
530
531    async def get_current_day_summary(self) -> Optional[Summary]:
532        """Return a summary for the current day.
533
534        Returns:
535            Optional[Summary]: A summary or None if not supported.
536
537        """
538        summary = await self.get_summary(
539            from_date=Arrow.now().date(),
540            to_date=Arrow.now().date(),
541            summary_type=SummaryType.DAILY,
542        )
543        min_no_of_summaries_required_for_calculation = 2
544        if len(summary) < min_no_of_summaries_required_for_calculation:
545            logger.info("Not enough summaries for calculation.")
546        return summary[0] if len(summary) > 0 else None
547
548    async def get_current_week_summary(self) -> Optional[Summary]:
549        """Return a summary for the current week.
550
551        Returns:
552            Optional[Summary]: A summary or None if not supported.
553
554        """
555        summary = await self.get_summary(
556            from_date=Arrow.now().floor("week").date(),
557            to_date=Arrow.now().date(),
558            summary_type=SummaryType.WEEKLY,
559        )
560        min_no_of_summaries_required_for_calculation = 2
561        if len(summary) < min_no_of_summaries_required_for_calculation:
562            logger.info("Not enough summaries for calculation.")
563        return summary[0] if len(summary) > 0 else None
564
565    async def get_current_month_summary(self) -> Optional[Summary]:
566        """Return a summary for the current month.
567
568        Returns:
569            Optional[Summary]: A summary or None if not supported.
570
571        """
572        summary = await self.get_summary(
573            from_date=Arrow.now().floor("month").date(),
574            to_date=Arrow.now().date(),
575            summary_type=SummaryType.MONTHLY,
576        )
577        min_no_of_summaries_required_for_calculation = 2
578        if len(summary) < min_no_of_summaries_required_for_calculation:
579            logger.info("Not enough summaries for calculation.")
580        return summary[0] if len(summary) > 0 else None
581
582    async def get_current_year_summary(self) -> Optional[Summary]:
583        """Return a summary for the current year.
584
585        Returns:
586            Optional[Summary]: A summary or None if not supported.
587
588        """
589        summary = await self.get_summary(
590            from_date=Arrow.now().floor("year").date(),
591            to_date=Arrow.now().date(),
592            summary_type=SummaryType.YEARLY,
593        )
594        min_no_of_summaries_required_for_calculation = 2
595        if len(summary) < min_no_of_summaries_required_for_calculation:
596            logger.info("Not enough summaries for calculation.")
597        return summary[0] if len(summary) > 0 else None
598
599    async def get_trips(
600        self,
601        from_date: date,
602        to_date: date,
603        full_route: bool = False,  # noqa : FBT001, FBT002
604    ) -> Optional[list[Trip]]:
605        """Return information on all trips made between the provided dates.
606
607        Args:
608            from_date (date, required): The inclusive from date
609            to_date (date, required): The inclusive to date
610            full_route (bool, optional): Provide the full route
611                                         information for each trip.
612
613        Returns:
614            Optional[list[Trip]]: A list of all trips or None if not supported.
615
616        """
617        ret: list[Trip] = []
618        offset = 0
619        while True:
620            resp = await self._api.get_trips(
621                self.vin,
622                from_date,
623                to_date,
624                summary=False,
625                limit=5,
626                offset=offset,
627                route=full_route,
628            )
629            if resp.payload is None:
630                break
631
632            # Convert to response
633            if resp.payload.trips:
634                ret.extend(Trip(t, self._metric) for t in resp.payload.trips)
635
636            offset = resp.payload.metadata.pagination.next_offset
637            if offset is None:
638                break
639
640        return ret
641
642    async def get_last_trip(self) -> Optional[Trip]:
643        """Return information on the last trip.
644
645        Returns:
646            Optional[Trip]: A trip model or None if not supported.
647
648        """
649        resp = await self._api.get_trips(
650            self.vin,
651            date.today() - timedelta(days=90),  # noqa : DTZ011
652            date.today(),  # noqa : DTZ011
653            summary=False,
654            limit=1,
655            offset=0,
656            route=False,
657        )
658
659        if resp.payload is None:
660            return None
661
662        ret = next(iter(resp.payload.trips), None)
663        return None if ret is None else Trip(ret, self._metric)
664
665    async def refresh_climate_status(self) -> StatusModel:
666        """Force update of climate status.
667
668        Returns:
669            StatusModel: A status response for the command.
670
671        """
672        return await self._api.refresh_climate_status(self.vin)
673
674    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
675        """Send remote command to the vehicle.
676
677        Args:
678            command (CommandType): The remote command model
679            beeps (int): Amount of beeps for commands that support it
680
681        Returns:
682            StatusModel: A status response for the command.
683
684        """
685        return await self._api.send_command(self.vin, command=command, beeps=beeps)
686
687    #
688    # More get functionality depending on what we find
689    #
690
691    async def set_alias(
692        self,
693        value: bool,  # noqa : FBT001
694    ) -> bool:
695        """Set the alias for the vehicle.
696
697        Args:
698            value: The alias value to set for the vehicle.
699
700        Returns:
701            bool: Indicator if value is set
702
703        """
704        return value
705
706    #
707    # More set functionality depending on what we find
708    #
709
710    def _dump_all(self) -> dict[str, Any]:
711        """Dump data from all endpoints for debugging and further work."""
712        dump: [str, Any] = {
713            "vehicle_info": json.loads(self._vehicle_info.model_dump_json())
714        }
715        for name, data in self._endpoint_data.items():
716            dump[name] = json.loads(data.model_dump_json())
717
718        return censor_all(copy.deepcopy(dump))
719
720    def _generate_daily_summaries(
721        self, summary: list[_SummaryItemModel]
722    ) -> list[Summary]:
723        summary.sort(key=attrgetter("year", "month"))
724        return [
725            Summary(
726                histogram.summary,
727                self._metric,
728                Arrow(histogram.year, histogram.month, histogram.day).date(),
729                Arrow(histogram.year, histogram.month, histogram.day).date(),
730                histogram.hdc,
731            )
732            for month in summary
733            for histogram in sorted(month.histograms, key=attrgetter("day"))
734        ]
735
736    def _generate_weekly_summaries(
737        self, summary: list[_SummaryItemModel]
738    ) -> list[Summary]:
739        ret: list[Summary] = []
740        summary.sort(key=attrgetter("year", "month"))
741
742        # Flatten the list of histograms
743        histograms = [histogram for month in summary for histogram in month.histograms]
744        histograms.sort(key=lambda h: date(day=h.day, month=h.month, year=h.year))
745
746        # Group histograms by week
747        for _, week_histograms_iter in groupby(
748            histograms, key=lambda h: Arrow(h.year, h.month, h.day).span("week")[0]
749        ):
750            week_histograms = list(week_histograms_iter)
751            build_hdc = copy.copy(week_histograms[0].hdc)
752            build_summary = copy.copy(week_histograms[0].summary)
753            start_date = Arrow(
754                week_histograms[0].year,
755                week_histograms[0].month,
756                week_histograms[0].day,
757            )
758
759            for histogram in week_histograms[1:]:
760                add_with_none(build_hdc, histogram.hdc)
761                build_summary += histogram.summary
762
763            end_date = Arrow(
764                week_histograms[-1].year,
765                week_histograms[-1].month,
766                week_histograms[-1].day,
767            )
768            ret.append(
769                Summary(
770                    build_summary,
771                    self._metric,
772                    start_date.date(),
773                    end_date.date(),
774                    build_hdc,
775                )
776            )
777
778        return ret
779
780    def _generate_monthly_summaries(
781        self, summary: list[_SummaryItemModel], from_date: date, to_date: date
782    ) -> list[Summary]:
783        # Convert all the monthly responses from the payload to a summary response
784        ret: list[Summary] = []
785        summary.sort(key=attrgetter("year", "month"))
786        for month in summary:
787            month_start = Arrow(month.year, month.month, 1).date()
788            month_end = (
789                Arrow(month.year, month.month, 1).shift(months=1).shift(days=-1).date()
790            )
791
792            ret.append(
793                Summary(
794                    month.summary,
795                    self._metric,
796                    # The data might not include an entire month
797                    # so update start and end dates.
798                    max(month_start, from_date),
799                    min(month_end, to_date),
800                    month.hdc,
801                )
802            )
803
804        return ret
805
806    def _generate_yearly_summaries(
807        self, summary: list[_SummaryItemModel], to_date: date
808    ) -> list[Summary]:
809        summary.sort(key=attrgetter("year", "month"))
810        ret: list[Summary] = []
811        build_hdc = copy.copy(summary[0].hdc)
812        build_summary = copy.copy(summary[0].summary)
813        start_date = date(day=1, month=summary[0].month, year=summary[0].year)
814
815        if len(summary) == 1:
816            ret.append(
817                Summary(build_summary, self._metric, start_date, to_date, build_hdc)
818            )
819        else:
820            for month, next_month in zip(
821                summary[1:], summary[2:] + [None], strict=False
822            ):
823                summary_month = date(day=1, month=month.month, year=month.year)
824                add_with_none(build_hdc, month.hdc)
825                build_summary += month.summary
826
827                if next_month is None or next_month.year != month.year:
828                    end_date = min(
829                        to_date, date(day=31, month=12, year=summary_month.year)
830                    )
831                    ret.append(
832                        Summary(
833                            build_summary, self._metric, start_date, end_date, build_hdc
834                        )
835                    )
836                    if next_month:
837                        start_date = date(
838                            day=1, month=next_month.month, year=next_month.year
839                        )
840                        build_hdc = copy.copy(next_month.hdc)
841                        build_summary = copy.copy(next_month.summary)
842
843        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            ret.extend(
415                ServiceHistory(service_history)
416                for service_history in payload.service_histories
417            )
418            return ret
419
420        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]:
422    def get_latest_service_history(self) -> Optional[ServiceHistory]:
423        r"""Return the latest service history entry for the vehicle.
424
425        Returns:
426            Optional[ServiceHistory]: A service history entry for the vehicle,
427                ordered by date and service_category. None if not supported or unknown.
428
429        """
430        if self.service_history is not None:
431            return max(
432                self.service_history, key=lambda x: (x.service_date, x.service_category)
433            )
434        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]
436    @computed_field  # type: ignore[prop-decorator]
437    @property
438    def lock_status(self) -> Optional[LockStatus]:
439        """Returns the latest lock status of Doors & Windows.
440
441        Returns:
442            Optional[LockStatus]: The latest lock status of Doors & Windows,
443                or None if not supported.
444
445        """
446        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]
448    @computed_field  # type: ignore[prop-decorator]
449    @property
450    def last_trip(self) -> Optional[Trip]:
451        """Returns the Vehicle last trip.
452
453        Returns:
454            Optional[Trip]: The last trip
455
456        """
457        ret = None
458        if "trip_history" in self._endpoint_data:
459            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
460
461        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]]
463    @computed_field  # type: ignore[prop-decorator]
464    @property
465    def trip_history(self) -> Optional[list[Trip]]:
466        """Returns the Vehicle trips.
467
468        Returns:
469            Optional[list[Trip]]: A list of trips
470
471        """
472        if "trip_history" in self._endpoint_data:
473            ret: list[Trip] = []
474            payload = self._endpoint_data["trip_history"].payload
475            ret.extend(Trip(t, self._metric) for t in payload.trips)
476            return ret
477
478        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]:
480    async def get_summary(
481        self,
482        from_date: date,
483        to_date: date,
484        summary_type: SummaryType = SummaryType.MONTHLY,
485    ) -> list[Summary]:
486        """Return different summarys between the provided dates.
487
488        All but Daily can return a partial time range. For example
489        if the summary_type is weekly and the date ranges selected
490        include partial weeks these partial weeks will be returned.
491        The dates contained in the summary will indicate the range
492        of dates that made up the partial week.
493
494        Note: Weekly and yearly summaries lose a small amount of
495        accuracy due to rounding issues.
496
497        Args:
498            from_date (date, required): The inclusive from date to report summaries.
499            to_date (date, required): The inclusive to date to report summaries.
500            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
501                Monthly by default.
502
503        Returns:
504            list[Summary]: A list of summaries or empty list if not supported.
505
506        """
507        to_date = min(to_date, date.today())  # noqa : DTZ011
508
509        # Summary information is always returned in the first response.
510        # No need to check all the following pages
511        resp = await self._api.get_trips(
512            self.vin, from_date, to_date, summary=True, limit=1, offset=0
513        )
514        if resp.payload is None or len(resp.payload.summary) == 0:
515            return []
516
517        # Convert to response
518        if summary_type == SummaryType.DAILY:
519            return self._generate_daily_summaries(resp.payload.summary)
520        if summary_type == SummaryType.WEEKLY:
521            return self._generate_weekly_summaries(resp.payload.summary)
522        if summary_type == SummaryType.MONTHLY:
523            return self._generate_monthly_summaries(
524                resp.payload.summary, from_date, to_date
525            )
526        if summary_type == SummaryType.YEARLY:
527            return self._generate_yearly_summaries(resp.payload.summary, to_date)
528        msg = "No such SummaryType"
529        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]:
531    async def get_current_day_summary(self) -> Optional[Summary]:
532        """Return a summary for the current day.
533
534        Returns:
535            Optional[Summary]: A summary or None if not supported.
536
537        """
538        summary = await self.get_summary(
539            from_date=Arrow.now().date(),
540            to_date=Arrow.now().date(),
541            summary_type=SummaryType.DAILY,
542        )
543        min_no_of_summaries_required_for_calculation = 2
544        if len(summary) < min_no_of_summaries_required_for_calculation:
545            logger.info("Not enough summaries for calculation.")
546        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]:
548    async def get_current_week_summary(self) -> Optional[Summary]:
549        """Return a summary for the current week.
550
551        Returns:
552            Optional[Summary]: A summary or None if not supported.
553
554        """
555        summary = await self.get_summary(
556            from_date=Arrow.now().floor("week").date(),
557            to_date=Arrow.now().date(),
558            summary_type=SummaryType.WEEKLY,
559        )
560        min_no_of_summaries_required_for_calculation = 2
561        if len(summary) < min_no_of_summaries_required_for_calculation:
562            logger.info("Not enough summaries for calculation.")
563        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]:
565    async def get_current_month_summary(self) -> Optional[Summary]:
566        """Return a summary for the current month.
567
568        Returns:
569            Optional[Summary]: A summary or None if not supported.
570
571        """
572        summary = await self.get_summary(
573            from_date=Arrow.now().floor("month").date(),
574            to_date=Arrow.now().date(),
575            summary_type=SummaryType.MONTHLY,
576        )
577        min_no_of_summaries_required_for_calculation = 2
578        if len(summary) < min_no_of_summaries_required_for_calculation:
579            logger.info("Not enough summaries for calculation.")
580        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]:
582    async def get_current_year_summary(self) -> Optional[Summary]:
583        """Return a summary for the current year.
584
585        Returns:
586            Optional[Summary]: A summary or None if not supported.
587
588        """
589        summary = await self.get_summary(
590            from_date=Arrow.now().floor("year").date(),
591            to_date=Arrow.now().date(),
592            summary_type=SummaryType.YEARLY,
593        )
594        min_no_of_summaries_required_for_calculation = 2
595        if len(summary) < min_no_of_summaries_required_for_calculation:
596            logger.info("Not enough summaries for calculation.")
597        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]]:
599    async def get_trips(
600        self,
601        from_date: date,
602        to_date: date,
603        full_route: bool = False,  # noqa : FBT001, FBT002
604    ) -> Optional[list[Trip]]:
605        """Return information on all trips made between the provided dates.
606
607        Args:
608            from_date (date, required): The inclusive from date
609            to_date (date, required): The inclusive to date
610            full_route (bool, optional): Provide the full route
611                                         information for each trip.
612
613        Returns:
614            Optional[list[Trip]]: A list of all trips or None if not supported.
615
616        """
617        ret: list[Trip] = []
618        offset = 0
619        while True:
620            resp = await self._api.get_trips(
621                self.vin,
622                from_date,
623                to_date,
624                summary=False,
625                limit=5,
626                offset=offset,
627                route=full_route,
628            )
629            if resp.payload is None:
630                break
631
632            # Convert to response
633            if resp.payload.trips:
634                ret.extend(Trip(t, self._metric) for t in resp.payload.trips)
635
636            offset = resp.payload.metadata.pagination.next_offset
637            if offset is None:
638                break
639
640        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]:
642    async def get_last_trip(self) -> Optional[Trip]:
643        """Return information on the last trip.
644
645        Returns:
646            Optional[Trip]: A trip model or None if not supported.
647
648        """
649        resp = await self._api.get_trips(
650            self.vin,
651            date.today() - timedelta(days=90),  # noqa : DTZ011
652            date.today(),  # noqa : DTZ011
653            summary=False,
654            limit=1,
655            offset=0,
656            route=False,
657        )
658
659        if resp.payload is None:
660            return None
661
662        ret = next(iter(resp.payload.trips), None)
663        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:
665    async def refresh_climate_status(self) -> StatusModel:
666        """Force update of climate status.
667
668        Returns:
669            StatusModel: A status response for the command.
670
671        """
672        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:
674    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
675        """Send remote command to the vehicle.
676
677        Args:
678            command (CommandType): The remote command model
679            beeps (int): Amount of beeps for commands that support it
680
681        Returns:
682            StatusModel: A status response for the command.
683
684        """
685        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:
691    async def set_alias(
692        self,
693        value: bool,  # noqa : FBT001
694    ) -> bool:
695        """Set the alias for the vehicle.
696
697        Args:
698            value: The alias value to set for the vehicle.
699
700        Returns:
701            bool: Indicator if value is set
702
703        """
704        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