pytoyoda.models.vehicle

Vehicle model.

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

Vehicle data representation.

async def update( self, skip: list[str] | None = None, only: list[str] | None = None) -> None:
244    async def update(
245        self,
246        skip: list[str] | None = None,
247        only: list[str] | None = None,
248    ) -> None:
249        """Update the data for the vehicle.
250
251        Endpoint functions are awaited sequentially rather than in a single
252        asyncio.gather. Toyota's API gateway appears to rate-limit on bursts
253        of near-simultaneous requests: firing ~10 requests in the same event
254        loop tick reliably trips a 429 with `{"description": "Unauthorized"}`
255        response bodies, while the same requests serialised at poll cadence
256        succeed cleanly. See pytoyoda/ha_toyota#282 for measurement evidence.
257
258        Args:
259            skip: Endpoint names (matching EndpointDefinition.name values
260                like "status", "telemetry", etc.) to skip this cycle.
261                Skipped endpoints retain their previous _endpoint_data
262                entry, so consumers continue to see the last-known value.
263                Used by ha_toyota's smart-refresh strategy to skip
264                /v1/global/remote/status when a separate POST/GET cycle
265                handles it explicitly.
266            only: Inverse of skip - if provided, ONLY these endpoint names
267                will be fetched. Mutually exclusive with skip.
268                Used by ha_toyota's smart-refresh strategy to update just
269                /v1/global/remote/status after a wake POST without
270                re-hitting the other endpoints that are already fresh.
271
272        Returns:
273            None
274
275        Raises:
276            ValueError: If both skip and only are provided.
277
278        """
279        if skip is not None and only is not None:
280            msg = "update(): pass either skip or only, not both"
281            raise ValueError(msg)
282        skip_set = set(skip or [])
283        only_set = set(only) if only is not None else None
284        for name, function in self._endpoint_collect:
285            if only_set is not None and name not in only_set:
286                continue
287            if name in skip_set:
288                continue
289            self._endpoint_data[name] = await function()

Update the data for the vehicle.

Endpoint functions are awaited sequentially rather than in a single asyncio.gather. Toyota's API gateway appears to rate-limit on bursts of near-simultaneous requests: firing ~10 requests in the same event loop tick reliably trips a 429 with {"description": "Unauthorized"} response bodies, while the same requests serialised at poll cadence succeed cleanly. See pytoyoda/ha_toyota#282 for measurement evidence.

Arguments:
  • skip: Endpoint names (matching EndpointDefinition.name values like "status", "telemetry", etc.) to skip this cycle. Skipped endpoints retain their previous _endpoint_data entry, so consumers continue to see the last-known value. Used by ha_toyota's smart-refresh strategy to skip /v1/global/remote/status when a separate POST/GET cycle handles it explicitly.
  • only: Inverse of skip - if provided, ONLY these endpoint names will be fetched. Mutually exclusive with skip. Used by ha_toyota's smart-refresh strategy to update just /v1/global/remote/status after a wake POST without re-hitting the other endpoints that are already fresh.
Returns:

None

Raises:
  • ValueError: If both skip and only are provided.
vin: str | None
291    @computed_field  # type: ignore[prop-decorator]
292    @property
293    def vin(self) -> str | None:
294        """Return the vehicles VIN number.
295
296        Returns:
297            Optional[str]: The vehicles VIN number
298
299        """
300        return self._vehicle_info.vin

Return the vehicles VIN number.

Returns:

Optional[str]: The vehicles VIN number

alias: str | None
302    @computed_field  # type: ignore[prop-decorator]
303    @property
304    def alias(self) -> str | None:
305        """Vehicle's alias.
306
307        Returns:
308            Optional[str]: Nickname of vehicle
309
310        """
311        return self._vehicle_info.nickname

Vehicle's alias.

Returns:

Optional[str]: Nickname of vehicle

type: str | None
313    @computed_field  # type: ignore[prop-decorator]
314    @property
315    def type(self) -> str | None:
316        """Returns the "type" of vehicle.
317
318        Returns:
319            Optional[str]: "fuel" if only fuel based
320                "mildhybrid" if hybrid
321                "phev" if plugin hybrid
322                "ev" if full electric vehicle
323
324        """
325        vehicle_type = VehicleType.from_vehicle_info(self._vehicle_info)
326        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: pytoyoda.models.dashboard.Dashboard | None
328    @computed_field  # type: ignore[prop-decorator]
329    @property
330    def dashboard(self) -> Dashboard | None:
331        """Returns the Vehicle dashboard.
332
333        The dashboard consists of items of information you would expect to
334        find on the dashboard. i.e. Fuel Levels.
335
336        Returns:
337            Optional[Dashboard]: A dashboard
338
339        """
340        # Always returns a Dashboard object as we can always get the odometer value
341        return Dashboard(
342            self._endpoint_data.get("telemetry", None),
343            self._endpoint_data.get("electric_status", None),
344            self._endpoint_data.get("health_status", None),
345            self._metric,
346        )

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: pytoyoda.models.climate.ClimateSettings | None
348    @computed_field  # type: ignore[prop-decorator]
349    @property
350    def climate_settings(self) -> ClimateSettings | None:
351        """Return the vehicle climate settings.
352
353        Returns:
354            Optional[ClimateSettings]: A climate settings
355
356        """
357        return ClimateSettings(self._endpoint_data.get("climate_settings", None))

Return the vehicle climate settings.

Returns:

Optional[ClimateSettings]: A climate settings

climate_status: pytoyoda.models.climate.ClimateStatus | None
359    @computed_field  # type: ignore[prop-decorator]
360    @property
361    def climate_status(self) -> ClimateStatus | None:
362        """Return the vehicle climate status.
363
364        Returns:
365            Optional[ClimateStatus]: A climate status
366
367        """
368        return ClimateStatus(self._endpoint_data.get("climate_status", None))

Return the vehicle climate status.

Returns:

Optional[ClimateStatus]: A climate status

electric_status: pytoyoda.models.electric_status.ElectricStatus | None
370    @computed_field  # type: ignore[prop-decorator]
371    @property
372    def electric_status(self) -> ElectricStatus | None:
373        """Returns the Electric Status of the vehicle.
374
375        Returns:
376            Optional[ElectricStatus]: Electric Status
377
378        """
379        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:
381    async def refresh_electric_realtime_status(self) -> StatusModel:
382        """Force update of electric realtime status.
383
384        This will drain the 12V battery of the vehicle if
385        used to often!
386
387        Returns:
388            StatusModel: A status response for the command.
389
390        """
391        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.

async def refresh_status( self) -> pytoyoda.models.endpoints.refresh_status.RefreshStatusResponseModel:
393    async def refresh_status(self) -> RefreshStatusResponseModel:
394        """Wake the vehicle and request a fresh /status cache populate.
395
396        Issues POST /v1/global/remote/refresh-status. Use sparingly:
397        each call uses cellular airtime and a small amount of 12V battery.
398        Returns when the gateway has accepted the wake request, NOT when
399        the cache has actually been populated; the caller should poll
400        /status afterwards (and check occurrence_date advancement) to
401        verify the wake succeeded end-to-end.
402
403        Returns:
404            RefreshStatusResponseModel: payload.return_code "000000"
405                = wake accepted, anything else = vehicle does not
406                support refresh-status (caller should disable further
407                attempts for this VIN).
408
409        """
410        return await self._api.refresh_vehicle_status(self.vin)

Wake the vehicle and request a fresh /status cache populate.

Issues POST /v1/global/remote/refresh-status. Use sparingly: each call uses cellular airtime and a small amount of 12V battery. Returns when the gateway has accepted the wake request, NOT when the cache has actually been populated; the caller should poll /status afterwards (and check occurrence_date advancement) to verify the wake succeeded end-to-end.

Returns:

RefreshStatusResponseModel: payload.return_code "000000" = wake accepted, anything else = vehicle does not support refresh-status (caller should disable further attempts for this VIN).

location: pytoyoda.models.location.Location | None
412    @computed_field  # type: ignore[prop-decorator]
413    @property
414    def location(self) -> Location | None:
415        """Return the vehicles latest reported Location.
416
417        Returns:
418            Optional[Location]: The latest location or None. If None vehicle car
419                does not support providing location information.
420                _Note_ an empty location object can be returned when the Vehicle
421                supports location but none is currently available.
422
423        """
424        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: list[pytoyoda.models.nofication.Notification] | None
426    @computed_field  # type: ignore[prop-decorator]
427    @property
428    def notifications(self) -> list[Notification] | None:
429        r"""Returns a list of notifications for the vehicle.
430
431        Returns:
432            Optional[list[Notification]]: A list of notifications for the vehicle,
433                or None if not supported.
434
435        """
436        if "notifications" in self._endpoint_data:
437            ret: list[Notification] = []
438            for p in self._endpoint_data["notifications"].payload:
439                ret.extend(Notification(n) for n in p.notifications)
440            return ret
441
442        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: list[pytoyoda.models.service_history.ServiceHistory] | None
444    @computed_field  # type: ignore[prop-decorator]
445    @property
446    def service_history(self) -> list[ServiceHistory] | None:
447        r"""Returns a list of service history entries for the vehicle.
448
449        Returns:
450            Optional[list[ServiceHistory]]: A list of service history entries
451                for the vehicle, or None if not supported.
452
453        """
454        if "service_history" in self._endpoint_data:
455            ret: list[ServiceHistory] = []
456            payload = self._endpoint_data["service_history"].payload
457            if not payload:
458                return None
459            ret.extend(
460                ServiceHistory(service_history)
461                for service_history in payload.service_histories
462            )
463            return ret
464
465        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) -> pytoyoda.models.service_history.ServiceHistory | None:
467    def get_latest_service_history(self) -> ServiceHistory | None:
468        r"""Return the latest service history entry for the vehicle.
469
470        Returns:
471            Optional[ServiceHistory]: A service history entry for the vehicle,
472                ordered by date and service_category. None if not supported or unknown.
473
474        """
475        if self.service_history is not None:
476            return max(
477                self.service_history, key=lambda x: (x.service_date, x.service_category)
478            )
479        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: pytoyoda.models.lock_status.LockStatus | None
481    @computed_field  # type: ignore[prop-decorator]
482    @property
483    def lock_status(self) -> LockStatus | None:
484        """Returns the latest lock status of Doors & Windows.
485
486        Returns:
487            Optional[LockStatus]: The latest lock status of Doors & Windows,
488                or None if not supported.
489
490        """
491        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: pytoyoda.models.trips.Trip | None
493    @computed_field  # type: ignore[prop-decorator]
494    @property
495    def last_trip(self) -> Trip | None:
496        """Returns the Vehicle last trip.
497
498        Returns:
499            Optional[Trip]: The last trip
500
501        """
502        ret = None
503        if "trip_history" in self._endpoint_data:
504            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
505
506        return None if ret is None else Trip(ret, self._metric)

Returns the Vehicle last trip.

Returns:

Optional[Trip]: The last trip

trip_history: list[pytoyoda.models.trips.Trip] | None
508    @computed_field  # type: ignore[prop-decorator]
509    @property
510    def trip_history(self) -> list[Trip] | None:
511        """Returns the Vehicle trips.
512
513        Returns:
514            Optional[list[Trip]]: A list of trips
515
516        """
517        if "trip_history" in self._endpoint_data:
518            ret: list[Trip] = []
519            payload = self._endpoint_data["trip_history"].payload
520            ret.extend(Trip(t, self._metric) for t in payload.trips)
521            return ret
522
523        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]:
525    async def get_summary(
526        self,
527        from_date: date,
528        to_date: date,
529        summary_type: SummaryType = SummaryType.MONTHLY,
530    ) -> list[Summary]:
531        """Return different summarys between the provided dates.
532
533        All but Daily can return a partial time range. For example
534        if the summary_type is weekly and the date ranges selected
535        include partial weeks these partial weeks will be returned.
536        The dates contained in the summary will indicate the range
537        of dates that made up the partial week.
538
539        Note: Weekly and yearly summaries lose a small amount of
540        accuracy due to rounding issues.
541
542        Args:
543            from_date (date, required): The inclusive from date to report summaries.
544            to_date (date, required): The inclusive to date to report summaries.
545            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
546                Monthly by default.
547
548        Returns:
549            list[Summary]: A list of summaries or empty list if not supported.
550
551        """
552        to_date = min(to_date, date.today())  # noqa : DTZ011
553
554        # Summary information is always returned in the first response.
555        # No need to check all the following pages
556        resp = await self._api.get_trips(
557            self.vin, from_date, to_date, summary=True, limit=1, offset=0
558        )
559        if resp.payload is None or len(resp.payload.summary) == 0:
560            return []
561
562        # Convert to response
563        if summary_type == SummaryType.DAILY:
564            return self._generate_daily_summaries(resp.payload.summary)
565        if summary_type == SummaryType.WEEKLY:
566            return self._generate_weekly_summaries(resp.payload.summary)
567        if summary_type == SummaryType.MONTHLY:
568            return self._generate_monthly_summaries(
569                resp.payload.summary, from_date, to_date
570            )
571        if summary_type == SummaryType.YEARLY:
572            return self._generate_yearly_summaries(resp.payload.summary, to_date)
573        msg = "No such SummaryType"
574        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) -> pytoyoda.models.summary.Summary | None:
576    async def get_current_day_summary(self) -> Summary | None:
577        """Return a summary for the current day.
578
579        Returns:
580            Optional[Summary]: A summary or None if not supported.
581
582        """
583        summary = await self.get_summary(
584            from_date=Arrow.now().date(),
585            to_date=Arrow.now().date(),
586            summary_type=SummaryType.DAILY,
587        )
588        min_no_of_summaries_required_for_calculation = 2
589        if len(summary) < min_no_of_summaries_required_for_calculation:
590            logger.info("Not enough summaries for calculation.")
591        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) -> pytoyoda.models.summary.Summary | None:
593    async def get_current_week_summary(self) -> Summary | None:
594        """Return a summary for the current week.
595
596        Returns:
597            Optional[Summary]: A summary or None if not supported.
598
599        """
600        summary = await self.get_summary(
601            from_date=Arrow.now().floor("week").date(),
602            to_date=Arrow.now().date(),
603            summary_type=SummaryType.WEEKLY,
604        )
605        min_no_of_summaries_required_for_calculation = 2
606        if len(summary) < min_no_of_summaries_required_for_calculation:
607            logger.info("Not enough summaries for calculation.")
608        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) -> pytoyoda.models.summary.Summary | None:
610    async def get_current_month_summary(self) -> Summary | None:
611        """Return a summary for the current month.
612
613        Returns:
614            Optional[Summary]: A summary or None if not supported.
615
616        """
617        summary = await self.get_summary(
618            from_date=Arrow.now().floor("month").date(),
619            to_date=Arrow.now().date(),
620            summary_type=SummaryType.MONTHLY,
621        )
622        min_no_of_summaries_required_for_calculation = 2
623        if len(summary) < min_no_of_summaries_required_for_calculation:
624            logger.info("Not enough summaries for calculation.")
625        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) -> pytoyoda.models.summary.Summary | None:
627    async def get_current_year_summary(self) -> Summary | None:
628        """Return a summary for the current year.
629
630        Returns:
631            Optional[Summary]: A summary or None if not supported.
632
633        """
634        summary = await self.get_summary(
635            from_date=Arrow.now().floor("year").date(),
636            to_date=Arrow.now().date(),
637            summary_type=SummaryType.YEARLY,
638        )
639        min_no_of_summaries_required_for_calculation = 2
640        if len(summary) < min_no_of_summaries_required_for_calculation:
641            logger.info("Not enough summaries for calculation.")
642        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) -> list[pytoyoda.models.trips.Trip] | None:
644    async def get_trips(
645        self,
646        from_date: date,
647        to_date: date,
648        full_route: bool = False,  # noqa : FBT001, FBT002
649    ) -> list[Trip] | None:
650        """Return information on all trips made between the provided dates.
651
652        Args:
653            from_date (date, required): The inclusive from date
654            to_date (date, required): The inclusive to date
655            full_route (bool, optional): Provide the full route
656                                         information for each trip.
657
658        Returns:
659            Optional[list[Trip]]: A list of all trips or None if not supported.
660
661        """
662        ret: list[Trip] = []
663        offset = 0
664        while True:
665            resp = await self._api.get_trips(
666                self.vin,
667                from_date,
668                to_date,
669                summary=False,
670                limit=5,
671                offset=offset,
672                route=full_route,
673            )
674            if resp.payload is None:
675                break
676
677            # Convert to response
678            if resp.payload.trips:
679                ret.extend(Trip(t, self._metric) for t in resp.payload.trips)
680
681            offset = resp.payload.metadata.pagination.next_offset
682            if offset is None:
683                break
684
685        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) -> pytoyoda.models.trips.Trip | None:
687    async def get_last_trip(self) -> Trip | None:
688        """Return information on the last trip.
689
690        Returns:
691            Optional[Trip]: A trip model or None if not supported.
692
693        """
694        resp = await self._api.get_trips(
695            self.vin,
696            date.today() - timedelta(days=90),  # noqa : DTZ011
697            date.today(),  # noqa : DTZ011
698            summary=False,
699            limit=1,
700            offset=0,
701            route=False,
702        )
703
704        if resp.payload is None:
705            return None
706
707        ret = next(iter(resp.payload.trips), None)
708        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:
710    async def refresh_climate_status(self) -> StatusModel:
711        """Force update of climate status.
712
713        Returns:
714            StatusModel: A status response for the command.
715
716        """
717        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:
719    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
720        """Send remote command to the vehicle.
721
722        Args:
723            command (CommandType): The remote command model
724            beeps (int): Amount of beeps for commands that support it
725
726        Returns:
727            StatusModel: A status response for the command.
728
729        """
730        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.

732    async def send_next_charging_command(
733        self, command: NextChargeSettings
734    ) -> ElectricCommandResponseModel:
735        """Send the next command to the vehicle.
736
737        Args:
738            command: NextChargeSettings command to send
739
740        Returns:
741            Model containing status of the command request
742
743        """
744        return await self._api.send_next_charging_command(self.vin, command=command)

Send the next command to the vehicle.

Arguments:
  • command: NextChargeSettings command to send
Returns:

Model containing status of the command request

async def set_alias(self, value: bool) -> bool:
750    async def set_alias(
751        self,
752        value: bool,  # noqa : FBT001
753    ) -> bool:
754        """Set the alias for the vehicle.
755
756        Args:
757            value: The alias value to set for the vehicle.
758
759        Returns:
760            bool: Indicator if value is set
761
762        """
763        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