pytoyoda.models.vehicle

Vehicle model.

   1"""Vehicle model."""
   2
   3import asyncio
   4import copy
   5import json
   6from collections.abc import Callable
   7from dataclasses import dataclass
   8from datetime import date, timedelta
   9from enum import Enum, auto
  10from functools import partial
  11from itertools import groupby
  12from operator import attrgetter
  13from typing import Any, TypeVar
  14
  15from arrow import Arrow
  16from loguru import logger
  17from pydantic import computed_field
  18
  19from pytoyoda.api import Api
  20from pytoyoda.exceptions import ToyotaApiError
  21from pytoyoda.models.climate import ClimateSettings, ClimateStatus
  22from pytoyoda.models.dashboard import Dashboard
  23from pytoyoda.models.electric_status import ElectricStatus
  24from pytoyoda.models.endpoints.climate import (
  25    RemoteClimateControlResponseModel,
  26    V2RemoteClimateControlRequestModel,
  27)
  28from pytoyoda.models.endpoints.command import CommandType
  29from pytoyoda.models.endpoints.common import StatusModel
  30from pytoyoda.models.endpoints.electric import (
  31    ElectricCommandResponseModel,
  32    NextChargeSettings,
  33)
  34from pytoyoda.models.endpoints.refresh_status import RefreshStatusResponseModel
  35from pytoyoda.models.endpoints.trips import _SummaryItemModel, _TripModel
  36from pytoyoda.models.endpoints.vehicle_guid import VehicleGuidModel
  37from pytoyoda.models.location import Location
  38from pytoyoda.models.lock_status import LockStatus
  39from pytoyoda.models.nofication import Notification
  40from pytoyoda.models.service_history import ServiceHistory
  41from pytoyoda.models.summary import Summary, SummaryType
  42from pytoyoda.models.trips import Trip
  43from pytoyoda.utils.helpers import add_with_none
  44from pytoyoda.utils.log_utils import censor_all
  45from pytoyoda.utils.models import CustomAPIBaseModel
  46
  47T = TypeVar(
  48    "T",
  49    bound=Api | VehicleGuidModel | bool,
  50)
  51
  52# Max parallel trip-detail requests when loading routes.
  53_ROUTE_FETCH_CONCURRENCY = 5
  54
  55
  56class VehicleType(Enum):
  57    """Vehicle types."""
  58
  59    PLUG_IN_HYBRID = auto()
  60    FULL_HYBRID = auto()
  61    ELECTRIC = auto()
  62    FUEL_ONLY = auto()
  63
  64    @classmethod
  65    def from_vehicle_info(cls, info: VehicleGuidModel) -> "VehicleType":
  66        """Determine the vehicle type based on detailed vehicle fuel information.
  67
  68        Args:
  69            info (VehicleGuidModel): Vehicle information model
  70
  71        Returns:
  72            VehicleType: Determined vehicle type
  73
  74        """
  75        try:
  76            if info.fuel_type == "B":
  77                vehicle_type = cls.FULL_HYBRID
  78            elif info.fuel_type == "E":
  79                vehicle_type = cls.ELECTRIC
  80            elif info.fuel_type == "I":
  81                vehicle_type = cls.PLUG_IN_HYBRID
  82            else:
  83                vehicle_type = cls.FUEL_ONLY
  84        except AttributeError:
  85            return cls.FUEL_ONLY
  86        else:
  87            return vehicle_type
  88
  89
  90@dataclass
  91class EndpointDefinition:
  92    """Definition of an API endpoint."""
  93
  94    name: str
  95    capable: bool
  96    function: Callable
  97    # If True, failures here are caught + recorded in
  98    # Vehicle._endpoint_errors instead of aborting update().
  99    optional: bool = False
 100
 101
 102class Vehicle(CustomAPIBaseModel[type[T]]):
 103    """Vehicle data representation."""
 104
 105    def __init__(
 106        self,
 107        api: Api,
 108        vehicle_info: VehicleGuidModel,
 109        metric: bool = True,  # noqa: FBT001, FBT002
 110        **kwargs: dict,
 111    ) -> None:
 112        """Initialise the Vehicle data representation."""
 113        data = {
 114            "api": api,
 115            "vehicle_info": vehicle_info,
 116            "metric": metric,
 117        }
 118        super().__init__(data=data, **kwargs)  # type: ignore[reportArgumentType, arg-type]
 119        self._api = api
 120        self._vehicle_info = vehicle_info
 121        self._metric = metric
 122        self._endpoint_data: dict[str, Any] = {}
 123
 124        if self._vehicle_info.vin:
 125            climate_capable = self._climate_capable()
 126            electric_capable = self._electric_capable()
 127            self._api_endpoints: list[EndpointDefinition] = [
 128                EndpointDefinition(
 129                    name="location",
 130                    capable=(
 131                        getattr(
 132                            getattr(self._vehicle_info, "extended_capabilities", False),
 133                            "last_parked_capable",
 134                            False,
 135                        )
 136                        or getattr(
 137                            getattr(self._vehicle_info, "features", False),
 138                            "last_parked",
 139                            False,
 140                        )
 141                    ),
 142                    function=partial(
 143                        self._api.get_location, vin=self._vehicle_info.vin
 144                    ),
 145                ),
 146                EndpointDefinition(
 147                    name="health_status",
 148                    capable=True,
 149                    function=partial(
 150                        self._api.get_vehicle_health_status,
 151                        vin=self._vehicle_info.vin,
 152                    ),
 153                ),
 154                EndpointDefinition(
 155                    name="electric_status",
 156                    capable=electric_capable,
 157                    function=partial(
 158                        self._api.get_vehicle_electric_status,
 159                        vin=self._vehicle_info.vin,
 160                    ),
 161                    # Vehicles wrongly reporting econnect_vehicle_status_capable
 162                    # (see _electric_capable) can still 4xx/5xx on this
 163                    # endpoint depending on account provisioning. See
 164                    # ha_toyota#302.
 165                    optional=True,
 166                ),
 167                EndpointDefinition(
 168                    name="telemetry",
 169                    capable=getattr(
 170                        getattr(self._vehicle_info, "extended_capabilities", False),
 171                        "telemetry_capable",
 172                        False,
 173                    ),
 174                    function=partial(
 175                        self._api.get_telemetry, vin=self._vehicle_info.vin
 176                    ),
 177                ),
 178                EndpointDefinition(
 179                    name="notifications",
 180                    capable=True,
 181                    function=partial(
 182                        self._api.get_notifications, vin=self._vehicle_info.vin
 183                    ),
 184                ),
 185                EndpointDefinition(
 186                    name="status",
 187                    capable=getattr(
 188                        getattr(self._vehicle_info, "extended_capabilities", False),
 189                        "vehicle_status",
 190                        False,
 191                    ),
 192                    function=partial(
 193                        self._api.get_remote_status, vin=self._vehicle_info.vin
 194                    ),
 195                ),
 196                EndpointDefinition(
 197                    name="service_history",
 198                    capable=getattr(
 199                        getattr(self._vehicle_info, "features", False),
 200                        "service_history",
 201                        False,
 202                    ),
 203                    function=partial(
 204                        self._api.get_service_history, vin=self._vehicle_info.vin
 205                    ),
 206                ),
 207                EndpointDefinition(
 208                    name="climate_settings",
 209                    capable=climate_capable,
 210                    function=partial(
 211                        self._api.get_climate_settings, vin=self._vehicle_info.vin
 212                    ),
 213                    # Toyota selectively 500s on this endpoint for some
 214                    # accounts. See ha_toyota#291.
 215                    optional=True,
 216                ),
 217                EndpointDefinition(
 218                    name="climate_status",
 219                    capable=climate_capable,
 220                    function=partial(
 221                        self._api.get_climate_status, vin=self._vehicle_info.vin
 222                    ),
 223                    optional=True,
 224                ),
 225                EndpointDefinition(
 226                    name="trip_history",
 227                    capable=True,
 228                    function=partial(
 229                        self._api.get_trips,
 230                        vin=self._vehicle_info.vin,
 231                        from_date=(date.today() - timedelta(days=90)),  # noqa: DTZ011
 232                        to_date=date.today(),  # noqa: DTZ011
 233                        summary=True,
 234                        limit=1,
 235                        offset=0,
 236                        route=False,
 237                    ),
 238                ),
 239            ]
 240        else:
 241            raise ToyotaApiError(
 242                logger.error(
 243                    "The VIN (vehicle identification number) "
 244                    "required for the end point request could not be determined"
 245                )
 246            )
 247        self._endpoint_collect = [
 248            endpoint for endpoint in self._api_endpoints if endpoint.capable
 249        ]
 250        # Failures on optional endpoints from the most recent update().
 251        self._endpoint_errors: dict[str, Exception] = {}
 252
 253    def _climate_capable(self) -> bool:
 254        """Determine whether the vehicle supports remote climate control.
 255
 256        Some vehicles (notably BEVs and certain PHEVs) advertise climate
 257        support only through ``extended_capabilities`` rather than through
 258        ``features.climate_start_engine``. Checking both keeps compatibility
 259        with ICE/HEV vehicles while widening detection for BEV/PHEV accounts.
 260        See issue #192.
 261        """
 262        extended_capabilities = getattr(
 263            self._vehicle_info, "extended_capabilities", False
 264        )
 265        return bool(
 266            getattr(
 267                getattr(self._vehicle_info, "features", False),
 268                "climate_start_engine",
 269                False,
 270            )
 271            or any(
 272                getattr(extended_capabilities, attr, False)
 273                for attr in (
 274                    "climate_capable",
 275                    "econnect_climate_capable",
 276                    "remote_engine_start_stop",
 277                )
 278            )
 279        )
 280
 281    def _electric_capable(self) -> bool:
 282        """Determine whether the vehicle supports electric/EV status data.
 283
 284        Some vehicles (notably rebadged Suzuki DCM24-backend BEVs, e.g. the
 285        Urban Cruiser) report ``econnect_vehicle_status_capable=False`` even
 286        though they are genuine electric vehicles - Toyota's vehicle
 287        registry can be wrong about EV status for these platforms, the same
 288        way it is wrong about climate capability for some accounts (see
 289        ``_climate_capable``). Falling back to ``fuel_type in ("E", "I")``
 290        and the top-level ``ev_vehicle`` flag widens detection without
 291        affecting ICE/HEV vehicles, whose fuel_type is never ``"E"``/``"I"``.
 292        ``"I"`` is Toyota's fuel_type code for plug-in hybrids, which can
 293        report the same electric_status data as full EVs.
 294        See pytoyoda/ha_toyota#302, pytoyoda#312.
 295        """
 296        extended_capabilities = getattr(
 297            self._vehicle_info, "extended_capabilities", False
 298        )
 299        return bool(
 300            getattr(extended_capabilities, "econnect_vehicle_status_capable", False)
 301            or getattr(self._vehicle_info, "ev_vehicle", False)
 302            or getattr(self._vehicle_info, "fuel_type", None) in ("E", "I")
 303        )
 304
 305    async def update(
 306        self,
 307        skip: list[str] | None = None,
 308        only: list[str] | None = None,
 309    ) -> None:
 310        """Update the data for the vehicle.
 311
 312        Endpoint functions are awaited sequentially rather than in a single
 313        asyncio.gather. Toyota's API gateway appears to rate-limit on bursts
 314        of near-simultaneous requests: firing ~10 requests in the same event
 315        loop tick reliably trips a 429 with `{"description": "Unauthorized"}`
 316        response bodies, while the same requests serialised at poll cadence
 317        succeed cleanly. See pytoyoda/ha_toyota#282 for measurement evidence.
 318
 319        Args:
 320            skip: Endpoint names (matching EndpointDefinition.name values
 321                like "status", "telemetry", etc.) to skip this cycle.
 322                Skipped endpoints retain their previous _endpoint_data
 323                entry, so consumers continue to see the last-known value.
 324                Used by ha_toyota's smart-refresh strategy to skip
 325                /v1/global/remote/status when a separate POST/GET cycle
 326                handles it explicitly.
 327            only: Inverse of skip - if provided, ONLY these endpoint names
 328                will be fetched. Mutually exclusive with skip.
 329                Used by ha_toyota's smart-refresh strategy to update just
 330                /v1/global/remote/status after a wake POST without
 331                re-hitting the other endpoints that are already fresh.
 332
 333        Endpoints registered with ``optional=True`` do not raise on failure:
 334        the exception is recorded in ``_endpoint_errors[name]`` and the
 335        endpoint's ``_endpoint_data`` entry is cleared, so downstream getters
 336        return None for that cycle instead of stale data (note the asymmetry
 337        with ``skip``, which retains the previous data). Error records are
 338        kept until the endpoint is attempted again, so partial updates via
 339        ``skip``/``only`` leave other endpoints' diagnostics intact.
 340
 341        Returns:
 342            None
 343
 344        Raises:
 345            ValueError: If both skip and only are provided.
 346
 347        """
 348        if skip is not None and only is not None:
 349            msg = "update(): pass either skip or only, not both"
 350            raise ValueError(msg)
 351        skip_set = set(skip or [])
 352        only_set = set(only) if only is not None else None
 353        for endpoint in self._endpoint_collect:
 354            if only_set is not None and endpoint.name not in only_set:
 355                continue
 356            if endpoint.name in skip_set:
 357                continue
 358            # Clear only the record of endpoints attempted this cycle, so a
 359            # partial update (skip/only) does not erase the diagnostics of
 360            # endpoints it never retried.
 361            self._endpoint_errors.pop(endpoint.name, None)
 362            try:
 363                self._endpoint_data[endpoint.name] = await endpoint.function()
 364            except Exception as ex:
 365                if not endpoint.optional:
 366                    raise
 367                self._endpoint_errors[endpoint.name] = ex
 368                # Clear any stale payload from a previous successful cycle so
 369                # downstream getters return None instead of outdated data.
 370                self._endpoint_data.pop(endpoint.name, None)
 371                logger.warning(
 372                    "Optional endpoint '{}' failed and will be cleared this "
 373                    "cycle: {}: {}",
 374                    endpoint.name,
 375                    type(ex).__name__,
 376                    ex,
 377                )
 378
 379    @computed_field  # type: ignore[prop-decorator]
 380    @property
 381    def vin(self) -> str | None:
 382        """Return the vehicles VIN number.
 383
 384        Returns:
 385            Optional[str]: The vehicles VIN number
 386
 387        """
 388        return self._vehicle_info.vin
 389
 390    @computed_field  # type: ignore[prop-decorator]
 391    @property
 392    def alias(self) -> str | None:
 393        """Vehicle's alias.
 394
 395        Returns:
 396            Optional[str]: Nickname of vehicle
 397
 398        """
 399        return self._vehicle_info.nickname
 400
 401    @computed_field  # type: ignore[prop-decorator]
 402    @property
 403    def type(self) -> str | None:
 404        """Returns the "type" of vehicle.
 405
 406        Returns:
 407            Optional[str]: "fuel" if only fuel based
 408                "mildhybrid" if hybrid
 409                "phev" if plugin hybrid
 410                "ev" if full electric vehicle
 411
 412        """
 413        vehicle_type = VehicleType.from_vehicle_info(self._vehicle_info)
 414        return vehicle_type.name.lower()
 415
 416    @computed_field  # type: ignore[prop-decorator]
 417    @property
 418    def dashboard(self) -> Dashboard | None:
 419        """Returns the Vehicle dashboard.
 420
 421        The dashboard consists of items of information you would expect to
 422        find on the dashboard. i.e. Fuel Levels.
 423
 424        Returns:
 425            Optional[Dashboard]: A dashboard
 426
 427        """
 428        # Always returns a Dashboard object as we can always get the odometer value
 429        return Dashboard(
 430            self._endpoint_data.get("telemetry", None),
 431            self._endpoint_data.get("electric_status", None),
 432            self._endpoint_data.get("health_status", None),
 433            self._metric,
 434        )
 435
 436    @computed_field  # type: ignore[prop-decorator]
 437    @property
 438    def climate_settings(self) -> ClimateSettings | None:
 439        """Return the vehicle climate settings.
 440
 441        Returns:
 442            Optional[ClimateSettings]: A climate settings
 443
 444        """
 445        return ClimateSettings(self._endpoint_data.get("climate_settings", None))
 446
 447    @computed_field  # type: ignore[prop-decorator]
 448    @property
 449    def climate_status(self) -> ClimateStatus | None:
 450        """Return the vehicle climate status.
 451
 452        Returns:
 453            Optional[ClimateStatus]: A climate status
 454
 455        """
 456        return ClimateStatus(self._endpoint_data.get("climate_status", None))
 457
 458    @computed_field  # type: ignore[prop-decorator]
 459    @property
 460    def electric_status(self) -> ElectricStatus | None:
 461        """Returns the Electric Status of the vehicle.
 462
 463        Returns:
 464            Optional[ElectricStatus]: Electric Status
 465
 466        """
 467        return ElectricStatus(self._endpoint_data.get("electric_status", None))
 468
 469    async def refresh_electric_realtime_status(self) -> StatusModel:
 470        """Force update of electric realtime status.
 471
 472        This will drain the 12V battery of the vehicle if
 473        used to often!
 474
 475        Returns:
 476            StatusModel: A status response for the command.
 477
 478        """
 479        return await self._api.refresh_electric_realtime_status(self.vin)
 480
 481    async def refresh_status(self) -> RefreshStatusResponseModel:
 482        """Wake the vehicle and request a fresh /status cache populate.
 483
 484        Issues POST /v1/remote/status. Use sparingly:
 485        each call uses cellular airtime and a small amount of 12V battery.
 486        Returns when the gateway has accepted the wake request, NOT when
 487        the cache has actually been populated; the caller should poll
 488        /status afterwards (and check occurrence_date advancement) to
 489        verify the wake succeeded end-to-end.
 490
 491        Returns:
 492            RefreshStatusResponseModel: payload.return_code "000000"
 493                = wake accepted, anything else = vehicle does not
 494                support refresh-status (caller should disable further
 495                attempts for this VIN).
 496
 497        """
 498        return await self._api.refresh_vehicle_status(self.vin)
 499
 500    @computed_field  # type: ignore[prop-decorator]
 501    @property
 502    def location(self) -> Location | None:
 503        """Return the vehicles latest reported Location.
 504
 505        Returns:
 506            Optional[Location]: The latest location or None. If None vehicle car
 507                does not support providing location information.
 508                _Note_ an empty location object can be returned when the Vehicle
 509                supports location but none is currently available.
 510
 511        """
 512        return Location(self._endpoint_data.get("location", None))
 513
 514    @computed_field  # type: ignore[prop-decorator]
 515    @property
 516    def notifications(self) -> list[Notification] | None:
 517        r"""Returns a list of notifications for the vehicle.
 518
 519        Returns:
 520            Optional[list[Notification]]: A list of notifications for the vehicle,
 521                or None if not supported.
 522
 523        """
 524        if "notifications" in self._endpoint_data:
 525            ret: list[Notification] = []
 526            for p in self._endpoint_data["notifications"].payload:
 527                ret.extend(Notification(n) for n in p.notifications)
 528            return ret
 529
 530        return None
 531
 532    @computed_field  # type: ignore[prop-decorator]
 533    @property
 534    def service_history(self) -> list[ServiceHistory] | None:
 535        r"""Returns a list of service history entries for the vehicle.
 536
 537        Returns:
 538            Optional[list[ServiceHistory]]: A list of service history entries
 539                for the vehicle, or None if not supported.
 540
 541        """
 542        if "service_history" in self._endpoint_data:
 543            ret: list[ServiceHistory] = []
 544            payload = self._endpoint_data["service_history"].payload
 545            if not payload:
 546                return None
 547            ret.extend(
 548                ServiceHistory(service_history)
 549                for service_history in payload.service_histories
 550            )
 551            return ret
 552
 553        return None
 554
 555    def get_latest_service_history(self) -> ServiceHistory | None:
 556        r"""Return the latest service history entry for the vehicle.
 557
 558        Returns:
 559            Optional[ServiceHistory]: A service history entry for the vehicle,
 560                ordered by date and service_category. None if not supported or unknown.
 561
 562        """
 563        if self.service_history is not None:
 564            return max(
 565                self.service_history, key=lambda x: (x.service_date, x.service_category)
 566            )
 567        return None
 568
 569    @computed_field  # type: ignore[prop-decorator]
 570    @property
 571    def lock_status(self) -> LockStatus | None:
 572        """Returns the latest lock status of Doors & Windows.
 573
 574        Returns:
 575            Optional[LockStatus]: The latest lock status of Doors & Windows,
 576                or None if not supported.
 577
 578        """
 579        return LockStatus(self._endpoint_data.get("status", None))
 580
 581    @computed_field  # type: ignore[prop-decorator]
 582    @property
 583    def last_trip(self) -> Trip | None:
 584        """Returns the Vehicle last trip.
 585
 586        Returns:
 587            Optional[Trip]: The last trip
 588
 589        """
 590        ret = None
 591        if "trip_history" in self._endpoint_data:
 592            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
 593
 594        return None if ret is None else Trip(ret, self._metric)
 595
 596    @computed_field  # type: ignore[prop-decorator]
 597    @property
 598    def trip_history(self) -> list[Trip] | None:
 599        """Returns the Vehicle trips.
 600
 601        Returns:
 602            Optional[list[Trip]]: A list of trips
 603
 604        """
 605        if "trip_history" in self._endpoint_data:
 606            ret: list[Trip] = []
 607            payload = self._endpoint_data["trip_history"].payload
 608            ret.extend(Trip(t, self._metric) for t in payload.trips)
 609            return ret
 610
 611        return None
 612
 613    async def get_summary(
 614        self,
 615        from_date: date,
 616        to_date: date,
 617        summary_type: SummaryType = SummaryType.MONTHLY,
 618        limit: int | None = None,
 619        offset: int = 0,
 620    ) -> list[Summary]:
 621        """Return different summarys between the provided dates.
 622
 623        All but Daily can return a partial time range. For example
 624        if the summary_type is weekly and the date ranges selected
 625        include partial weeks these partial weeks will be returned.
 626        The dates contained in the summary will indicate the range
 627        of dates that made up the partial week.
 628
 629        Note: Weekly and yearly summaries lose a small amount of
 630        accuracy due to rounding issues.
 631
 632        Args:
 633            from_date (date, required): The inclusive from date to report summaries.
 634            to_date (date, required): The inclusive to date to report summaries.
 635            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
 636                Monthly by default.
 637            limit (int | None, optional): Maximum number of summaries to
 638                return. ``None`` (the default) returns every summary in the
 639                requested date range, preserving the previous behaviour.
 640            offset (int, optional): Number of summaries to skip from the
 641                start of the (chronologically ordered) result set before
 642                applying ``limit``. Defaults to ``0``.
 643
 644        Returns:
 645            list[Summary]: A list of summaries or empty list if not supported.
 646
 647        Raises:
 648            ValueError: ``limit`` is not ``None`` and is less than 1, or
 649                ``offset`` is negative.
 650
 651        """
 652        if limit is not None and limit < 1:
 653            msg = f"limit must be >= 1, got {limit}"
 654            raise ValueError(msg)
 655        if offset < 0:
 656            msg = f"offset must be >= 0, got {offset}"
 657            raise ValueError(msg)
 658
 659        to_date = min(to_date, date.today())  # noqa : DTZ011
 660
 661        # Summary information is always returned in the first response.
 662        # No need to check all the following pages
 663        resp = await self._api.get_trips(
 664            self.vin, from_date, to_date, summary=True, limit=1, offset=0
 665        )
 666        if resp.payload is None or len(resp.payload.summary) == 0:
 667            return []
 668
 669        # Convert to response
 670        if summary_type == SummaryType.DAILY:
 671            summaries = self._generate_daily_summaries(resp.payload.summary)
 672        elif summary_type == SummaryType.WEEKLY:
 673            summaries = self._generate_weekly_summaries(resp.payload.summary)
 674        elif summary_type == SummaryType.MONTHLY:
 675            summaries = self._generate_monthly_summaries(
 676                resp.payload.summary, from_date, to_date
 677            )
 678        elif summary_type == SummaryType.YEARLY:
 679            summaries = self._generate_yearly_summaries(resp.payload.summary, to_date)
 680        else:
 681            msg = "No such SummaryType"
 682            raise AssertionError(msg)
 683
 684        if offset:
 685            summaries = summaries[offset:]
 686        if limit is not None:
 687            summaries = summaries[:limit]
 688        return summaries
 689
 690    async def get_current_day_summary(self) -> Summary | None:
 691        """Return a summary for the current day.
 692
 693        Returns:
 694            Optional[Summary]: A summary or None if not supported.
 695
 696        """
 697        summary = await self.get_summary(
 698            from_date=Arrow.now().date(),
 699            to_date=Arrow.now().date(),
 700            summary_type=SummaryType.DAILY,
 701        )
 702        min_no_of_summaries_required_for_calculation = 2
 703        if len(summary) < min_no_of_summaries_required_for_calculation:
 704            logger.info("Not enough summaries for calculation.")
 705        return summary[0] if len(summary) > 0 else None
 706
 707    async def get_current_week_summary(self) -> Summary | None:
 708        """Return a summary for the current week.
 709
 710        Returns:
 711            Optional[Summary]: A summary or None if not supported.
 712
 713        """
 714        summary = await self.get_summary(
 715            from_date=Arrow.now().floor("week").date(),
 716            to_date=Arrow.now().date(),
 717            summary_type=SummaryType.WEEKLY,
 718        )
 719        min_no_of_summaries_required_for_calculation = 2
 720        if len(summary) < min_no_of_summaries_required_for_calculation:
 721            logger.info("Not enough summaries for calculation.")
 722        return summary[0] if len(summary) > 0 else None
 723
 724    async def get_current_month_summary(self) -> Summary | None:
 725        """Return a summary for the current month.
 726
 727        Returns:
 728            Optional[Summary]: A summary or None if not supported.
 729
 730        """
 731        summary = await self.get_summary(
 732            from_date=Arrow.now().floor("month").date(),
 733            to_date=Arrow.now().date(),
 734            summary_type=SummaryType.MONTHLY,
 735        )
 736        min_no_of_summaries_required_for_calculation = 2
 737        if len(summary) < min_no_of_summaries_required_for_calculation:
 738            logger.info("Not enough summaries for calculation.")
 739        return summary[0] if len(summary) > 0 else None
 740
 741    async def get_current_year_summary(self) -> Summary | None:
 742        """Return a summary for the current year.
 743
 744        Returns:
 745            Optional[Summary]: A summary or None if not supported.
 746
 747        """
 748        summary = await self.get_summary(
 749            from_date=Arrow.now().floor("year").date(),
 750            to_date=Arrow.now().date(),
 751            summary_type=SummaryType.YEARLY,
 752        )
 753        min_no_of_summaries_required_for_calculation = 2
 754        if len(summary) < min_no_of_summaries_required_for_calculation:
 755            logger.info("Not enough summaries for calculation.")
 756        return summary[0] if len(summary) > 0 else None
 757
 758    async def get_trips(
 759        self,
 760        from_date: date,
 761        to_date: date,
 762        full_route: bool = False,  # noqa : FBT001, FBT002
 763        limit: int | None = None,
 764        offset: int = 0,
 765    ) -> list[Trip] | None:
 766        """Return information on trips made between the provided dates.
 767
 768        Args:
 769            from_date (date, required): The inclusive from date
 770            to_date (date, required): The inclusive to date
 771            full_route (bool, optional): Provide the full route
 772                information for each trip. Costs one extra API call per
 773                trip, as routes are only served by the trip detail endpoint.
 774            limit (int | None, optional): Maximum total number of trips
 775                to return to the caller. ``None`` (the default) returns every
 776                trip in the requested date range, preserving the previous
 777                behaviour of paginating through all pages. If set, at most
 778                ``limit`` trips are returned, fetching only as many pages
 779                from the API as necessary.
 780            offset (int, optional): Number of trips to skip from the
 781                start of the (most-recent-first) result set before applying
 782                ``limit``. Defaults to ``0``.
 783
 784        Returns:
 785            Optional[list[Trip]]: A list of trips (bounded by ``limit`` when
 786            given) or None if not supported.
 787
 788        Raises:
 789            ValueError: ``limit`` is not ``None`` and is less than 1, or
 790                ``offset`` is negative.
 791
 792        """
 793        if limit is not None and limit < 1:
 794            msg = f"limit must be >= 1, got {limit}"
 795            raise ValueError(msg)
 796        if offset < 0:
 797            msg = f"offset must be >= 0, got {offset}"
 798            raise ValueError(msg)
 799
 800        page_size = 5
 801        collected: list[_TripModel] = []
 802        skipped = 0
 803        api_offset = 0
 804        while True:
 805            # Once limit is known we can stop as soon as we've collected
 806            # enough trips beyond the requested offset - no need to keep
 807            # paginating through the entire remaining result set.
 808            resp = await self._api.get_trips(
 809                self.vin,
 810                from_date,
 811                to_date,
 812                summary=False,
 813                limit=page_size,
 814                offset=api_offset,
 815            )
 816            if resp.payload is None:
 817                break
 818
 819            for t in resp.payload.trips or []:
 820                if skipped < offset:
 821                    skipped += 1
 822                    continue
 823                collected.append(t)
 824                if limit is not None and len(collected) >= limit:
 825                    break
 826
 827            api_offset = resp.payload.metadata.pagination.next_offset
 828            if api_offset is None or (limit is not None and len(collected) >= limit):
 829                break
 830
 831        if full_route:
 832            await self._load_routes(collected)
 833        return [Trip(t, self._metric) for t in collected]
 834
 835    async def get_last_trip(self, offset: int = 0) -> Trip | None:
 836        """Return information on the Nth-most-recent trip.
 837
 838        Args:
 839            offset (int, optional): 0-based position in the
 840                most-recent-first trip history. ``0`` (the default) returns
 841                the most recent trip, ``1`` returns the second most recent
 842                trip, and so on.
 843
 844        Returns:
 845            Optional[Trip]: A trip model, or None if not supported or if
 846            there is no trip at the requested ``offset``.
 847
 848        Raises:
 849            ValueError: ``offset`` is negative.
 850
 851        """
 852        trips = await self.get_trips(
 853            date.today() - timedelta(days=90),  # noqa : DTZ011
 854            date.today(),  # noqa : DTZ011
 855            full_route=False,
 856            limit=1,
 857            offset=offset,
 858        )
 859
 860        if not trips:
 861            return None
 862        return trips[0]
 863
 864    async def get_recent_trips(
 865        self,
 866        limit: int = 5,
 867        with_route: bool = False,  # noqa: FBT001, FBT002
 868        from_date: date | None = None,
 869        to_date: date | None = None,
 870        offset: int = 0,
 871    ) -> list[Trip]:
 872        """Fetch a single page of trips, most-recent-first (one HTTP call).
 873
 874        Args:
 875            limit: Page size, 1..50.
 876            with_route: Include per-trip route coordinates (one extra API call
 877                per trip).
 878            from_date: Lower bound, inclusive. Defaults to today minus 90 days.
 879            to_date: Upper bound, inclusive. Defaults to today.
 880            offset: 0-based offset into the most-recent-first result set.
 881
 882        Returns:
 883            List of Trip models, empty if no trips match.
 884
 885        Raises:
 886            ValueError: ``limit`` outside 1..50, or ``offset`` negative.
 887
 888        """
 889        if not 1 <= limit <= 50:  # noqa: PLR2004
 890            msg = f"limit must be between 1 and 50, got {limit}"
 891            raise ValueError(msg)
 892        if offset < 0:
 893            msg = f"offset must be >= 0, got {offset}"
 894            raise ValueError(msg)
 895        if from_date is None:
 896            from_date = date.today() - timedelta(days=90)  # noqa: DTZ011
 897        if to_date is None:
 898            to_date = date.today()  # noqa: DTZ011
 899
 900        resp = await self._api.get_trips(
 901            self.vin,
 902            from_date,
 903            to_date,
 904            summary=False,
 905            limit=limit,
 906            offset=offset,
 907        )
 908
 909        if resp.payload is None or not resp.payload.trips:
 910            return []
 911
 912        if with_route:
 913            await self._load_routes(resp.payload.trips)
 914        return [Trip(t, self._metric) for t in resp.payload.trips]
 915
 916    async def _load_routes(self, trips: list[_TripModel]) -> None:
 917        """Fill in the route of each trip from the trip detail endpoint.
 918
 919        The trip list endpoint does not return routes; they are only available
 920        per trip, so this costs one HTTP call per trip.
 921        """
 922        semaphore = asyncio.Semaphore(_ROUTE_FETCH_CONCURRENCY)
 923
 924        async def load(trip: _TripModel) -> None:
 925            if trip.id is None:
 926                return
 927            async with semaphore:
 928                resp = await self._api.get_trip_details(self.vin, trip.id)
 929            if resp.payload is not None:
 930                trip.route = resp.payload.route
 931
 932        await asyncio.gather(*(load(t) for t in trips))
 933
 934    async def refresh_climate_status(self) -> StatusModel:
 935        """Force update of climate status.
 936
 937        Returns:
 938            StatusModel: A status response for the command.
 939
 940        """
 941        return await self._api.refresh_climate_status(self.vin)
 942
 943    async def set_climate(
 944        self, request: V2RemoteClimateControlRequestModel
 945    ) -> RemoteClimateControlResponseModel:
 946        """Start or stop remote climate control (POST /v2/remote/climate-control).
 947
 948        A ``start`` request carries the full desired settings (temperature +
 949        heating/seat options + ``save_settings``); a ``stop`` is just
 950        ``command="stop"``. Acknowledgement is ``response.payload.return_code ==
 951        "000000"``; confirm the actual on/off state via the climate-status read.
 952
 953        Args:
 954            request: The V2 climate-control request body.
 955
 956        Returns:
 957            RemoteClimateControlResponseModel: The command acknowledgement.
 958
 959        """
 960        return await self._api.send_climate_control_command(self.vin, request)
 961
 962    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
 963        """Send remote command to the vehicle.
 964
 965        Args:
 966            command (CommandType): The remote command model
 967            beeps (int): Amount of beeps for commands that support it
 968
 969        Returns:
 970            StatusModel: A status response for the command.
 971
 972        """
 973        return await self._api.send_command(self.vin, command=command, beeps=beeps)
 974
 975    async def send_next_charging_command(
 976        self, command: NextChargeSettings
 977    ) -> ElectricCommandResponseModel:
 978        """Send the next command to the vehicle.
 979
 980        Args:
 981            command: NextChargeSettings command to send
 982
 983        Returns:
 984            Model containing status of the command request
 985
 986        """
 987        return await self._api.send_next_charging_command(self.vin, command=command)
 988
 989    #
 990    # More get functionality depending on what we find
 991    #
 992
 993    async def set_alias(
 994        self,
 995        value: bool,  # noqa : FBT001
 996    ) -> bool:
 997        """Set the alias for the vehicle.
 998
 999        Args:
1000            value: The alias value to set for the vehicle.
1001
1002        Returns:
1003            bool: Indicator if value is set
1004
1005        """
1006        return value
1007
1008    #
1009    # More set functionality depending on what we find
1010    #
1011
1012    def _dump_all(self) -> dict[str, Any]:
1013        """Dump data from all endpoints for debugging and further work."""
1014        dump: [str, Any] = {
1015            "vehicle_info": json.loads(self._vehicle_info.model_dump_json())
1016        }
1017        for name, data in self._endpoint_data.items():
1018            dump[name] = json.loads(data.model_dump_json())
1019
1020        return censor_all(copy.deepcopy(dump))
1021
1022    def _generate_daily_summaries(
1023        self, summary: list[_SummaryItemModel]
1024    ) -> list[Summary]:
1025        summary.sort(key=attrgetter("year", "month"))
1026        # Skip histograms with summary=None - a hollow Summary crashes
1027        # downstream when sensors read its properties (see #278).
1028        return [
1029            Summary(
1030                histogram.summary,
1031                self._metric,
1032                Arrow(histogram.year, histogram.month, histogram.day).date(),
1033                Arrow(histogram.year, histogram.month, histogram.day).date(),
1034                histogram.hdc,
1035                [histogram.scores.global_]
1036                if histogram.scores and histogram.scores.global_ is not None
1037                else None,
1038            )
1039            for month in summary
1040            for histogram in sorted(month.histograms, key=attrgetter("day"))
1041            if histogram.summary is not None
1042        ]
1043
1044    def _generate_weekly_summaries(
1045        self, summary: list[_SummaryItemModel]
1046    ) -> list[Summary]:
1047        ret: list[Summary] = []
1048        summary.sort(key=attrgetter("year", "month"))
1049
1050        # Flatten the list of histograms
1051        histograms = [histogram for month in summary for histogram in month.histograms]
1052        histograms.sort(key=lambda h: date(day=h.day, month=h.month, year=h.year))
1053
1054        # Group histograms by week
1055        for _, week_histograms_iter in groupby(
1056            histograms, key=lambda h: Arrow(h.year, h.month, h.day).span("week")[0]
1057        ):
1058            week_histograms = list(week_histograms_iter)
1059            build_hdc = copy.copy(week_histograms[0].hdc)
1060            build_summary = copy.copy(week_histograms[0].summary)
1061            build_scores: list[int] = [
1062                h.scores.global_
1063                for h in week_histograms
1064                if h.scores and h.scores.global_ is not None
1065            ]
1066            start_date = Arrow(
1067                week_histograms[0].year,
1068                week_histograms[0].month,
1069                week_histograms[0].day,
1070            )
1071
1072            for histogram in week_histograms[1:]:
1073                # ``add_with_none`` returns the sum, so we must capture it;
1074                # without the assignment ``build_hdc`` would stay at the
1075                # first histogram's hdc (or ``None`` if that was None).
1076                build_hdc = add_with_none(build_hdc, histogram.hdc)
1077                # histogram.summary (and the seed build_summary) may be None on
1078                # days where the Toyota API returned a partial payload. Seed with
1079                # the first non-None summary we see, then accumulate.
1080                if histogram.summary is None:
1081                    continue
1082                if build_summary is None:
1083                    build_summary = copy.copy(histogram.summary)
1084                else:
1085                    build_summary += histogram.summary
1086
1087            end_date = Arrow(
1088                week_histograms[-1].year,
1089                week_histograms[-1].month,
1090                week_histograms[-1].day,
1091            )
1092            # Skip weeks where every histogram.summary was None - a hollow
1093            # Summary crashes downstream when sensors read its properties.
1094            if build_summary is None:
1095                continue
1096            ret.append(
1097                Summary(
1098                    build_summary,
1099                    self._metric,
1100                    start_date.date(),
1101                    end_date.date(),
1102                    build_hdc,
1103                    build_scores or None,
1104                )
1105            )
1106
1107        return ret
1108
1109    def _generate_monthly_summaries(
1110        self, summary: list[_SummaryItemModel], from_date: date, to_date: date
1111    ) -> list[Summary]:
1112        # Convert all the monthly responses from the payload to a summary response
1113        ret: list[Summary] = []
1114        summary.sort(key=attrgetter("year", "month"))
1115        for month in summary:
1116            # Skip months with summary=None - a hollow Summary crashes
1117            # downstream when sensors read its properties (see #278).
1118            if month.summary is None:
1119                continue
1120            month_start = Arrow(month.year, month.month, 1).date()
1121            month_end = (
1122                Arrow(month.year, month.month, 1).shift(months=1).shift(days=-1).date()
1123            )
1124
1125            ret.append(
1126                Summary(
1127                    month.summary,
1128                    self._metric,
1129                    # The data might not include an entire month
1130                    # so update start and end dates.
1131                    max(month_start, from_date),
1132                    min(month_end, to_date),
1133                    month.hdc,
1134                    [month.scores.global_]
1135                    if month.scores and month.scores.global_ is not None
1136                    else None,
1137                )
1138            )
1139
1140        return ret
1141
1142    def _generate_yearly_summaries(
1143        self, summary: list[_SummaryItemModel], to_date: date
1144    ) -> list[Summary]:
1145        summary.sort(key=attrgetter("year", "month"))
1146        ret: list[Summary] = []
1147        build_hdc = copy.copy(summary[0].hdc)
1148        build_summary = copy.copy(summary[0].summary)
1149        build_scores: list[int] = (
1150            [summary[0].scores.global_]
1151            if summary[0].scores and summary[0].scores.global_ is not None
1152            else []
1153        )
1154        start_date = date(day=1, month=summary[0].month, year=summary[0].year)
1155
1156        if len(summary) == 1:
1157            if build_summary is not None:
1158                ret.append(
1159                    Summary(
1160                        build_summary,
1161                        self._metric,
1162                        start_date,
1163                        to_date,
1164                        build_hdc,
1165                        build_scores or None,
1166                    )
1167                )
1168        else:
1169            for month, next_month in zip(
1170                summary[1:], [*summary[2:], None], strict=False
1171            ):
1172                summary_month = date(day=1, month=month.month, year=month.year)
1173                # ``add_with_none`` returns the sum; capture it or ``build_hdc``
1174                # stays at the year's first month's hdc.
1175                build_hdc = add_with_none(build_hdc, month.hdc)
1176                # month.summary (and the seed build_summary) may be None when
1177                # the Toyota API returned partial data.
1178                if month.summary is not None:
1179                    if build_summary is None:
1180                        build_summary = copy.copy(month.summary)
1181                    else:
1182                        build_summary += month.summary
1183                if month.scores and month.scores.global_ is not None:
1184                    build_scores.append(month.scores.global_)
1185
1186                if next_month is None or next_month.year != month.year:
1187                    end_date = min(
1188                        to_date, date(day=31, month=12, year=summary_month.year)
1189                    )
1190                    # Skip years where every month.summary was None - a hollow
1191                    # Summary crashes downstream when sensors read its properties.
1192                    if build_summary is not None:
1193                        ret.append(
1194                            Summary(
1195                                build_summary,
1196                                self._metric,
1197                                start_date,
1198                                end_date,
1199                                build_hdc,
1200                                build_scores or None,
1201                            )
1202                        )
1203                    if next_month:
1204                        start_date = date(
1205                            day=1, month=next_month.month, year=next_month.year
1206                        )
1207                        build_hdc = copy.copy(next_month.hdc)
1208                        build_summary = copy.copy(next_month.summary)
1209                        build_scores = (
1210                            [next_month.scores.global_]
1211                            if next_month.scores
1212                            and next_month.scores.global_ is not None
1213                            else []
1214                        )
1215
1216        return ret
class VehicleType(enum.Enum):
57class VehicleType(Enum):
58    """Vehicle types."""
59
60    PLUG_IN_HYBRID = auto()
61    FULL_HYBRID = auto()
62    ELECTRIC = auto()
63    FUEL_ONLY = auto()
64
65    @classmethod
66    def from_vehicle_info(cls, info: VehicleGuidModel) -> "VehicleType":
67        """Determine the vehicle type based on detailed vehicle fuel information.
68
69        Args:
70            info (VehicleGuidModel): Vehicle information model
71
72        Returns:
73            VehicleType: Determined vehicle type
74
75        """
76        try:
77            if info.fuel_type == "B":
78                vehicle_type = cls.FULL_HYBRID
79            elif info.fuel_type == "E":
80                vehicle_type = cls.ELECTRIC
81            elif info.fuel_type == "I":
82                vehicle_type = cls.PLUG_IN_HYBRID
83            else:
84                vehicle_type = cls.FUEL_ONLY
85        except AttributeError:
86            return cls.FUEL_ONLY
87        else:
88            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:
65    @classmethod
66    def from_vehicle_info(cls, info: VehicleGuidModel) -> "VehicleType":
67        """Determine the vehicle type based on detailed vehicle fuel information.
68
69        Args:
70            info (VehicleGuidModel): Vehicle information model
71
72        Returns:
73            VehicleType: Determined vehicle type
74
75        """
76        try:
77            if info.fuel_type == "B":
78                vehicle_type = cls.FULL_HYBRID
79            elif info.fuel_type == "E":
80                vehicle_type = cls.ELECTRIC
81            elif info.fuel_type == "I":
82                vehicle_type = cls.PLUG_IN_HYBRID
83            else:
84                vehicle_type = cls.FUEL_ONLY
85        except AttributeError:
86            return cls.FUEL_ONLY
87        else:
88            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:
 91@dataclass
 92class EndpointDefinition:
 93    """Definition of an API endpoint."""
 94
 95    name: str
 96    capable: bool
 97    function: Callable
 98    # If True, failures here are caught + recorded in
 99    # Vehicle._endpoint_errors instead of aborting update().
100    optional: bool = False

Definition of an API endpoint.

EndpointDefinition(name: str, capable: bool, function: Callable, optional: bool = False)
name: str
capable: bool
function: Callable
optional: bool = False
class Vehicle(pydantic.main.BaseModel, typing.Generic[~T]):
 103class Vehicle(CustomAPIBaseModel[type[T]]):
 104    """Vehicle data representation."""
 105
 106    def __init__(
 107        self,
 108        api: Api,
 109        vehicle_info: VehicleGuidModel,
 110        metric: bool = True,  # noqa: FBT001, FBT002
 111        **kwargs: dict,
 112    ) -> None:
 113        """Initialise the Vehicle data representation."""
 114        data = {
 115            "api": api,
 116            "vehicle_info": vehicle_info,
 117            "metric": metric,
 118        }
 119        super().__init__(data=data, **kwargs)  # type: ignore[reportArgumentType, arg-type]
 120        self._api = api
 121        self._vehicle_info = vehicle_info
 122        self._metric = metric
 123        self._endpoint_data: dict[str, Any] = {}
 124
 125        if self._vehicle_info.vin:
 126            climate_capable = self._climate_capable()
 127            electric_capable = self._electric_capable()
 128            self._api_endpoints: list[EndpointDefinition] = [
 129                EndpointDefinition(
 130                    name="location",
 131                    capable=(
 132                        getattr(
 133                            getattr(self._vehicle_info, "extended_capabilities", False),
 134                            "last_parked_capable",
 135                            False,
 136                        )
 137                        or getattr(
 138                            getattr(self._vehicle_info, "features", False),
 139                            "last_parked",
 140                            False,
 141                        )
 142                    ),
 143                    function=partial(
 144                        self._api.get_location, vin=self._vehicle_info.vin
 145                    ),
 146                ),
 147                EndpointDefinition(
 148                    name="health_status",
 149                    capable=True,
 150                    function=partial(
 151                        self._api.get_vehicle_health_status,
 152                        vin=self._vehicle_info.vin,
 153                    ),
 154                ),
 155                EndpointDefinition(
 156                    name="electric_status",
 157                    capable=electric_capable,
 158                    function=partial(
 159                        self._api.get_vehicle_electric_status,
 160                        vin=self._vehicle_info.vin,
 161                    ),
 162                    # Vehicles wrongly reporting econnect_vehicle_status_capable
 163                    # (see _electric_capable) can still 4xx/5xx on this
 164                    # endpoint depending on account provisioning. See
 165                    # ha_toyota#302.
 166                    optional=True,
 167                ),
 168                EndpointDefinition(
 169                    name="telemetry",
 170                    capable=getattr(
 171                        getattr(self._vehicle_info, "extended_capabilities", False),
 172                        "telemetry_capable",
 173                        False,
 174                    ),
 175                    function=partial(
 176                        self._api.get_telemetry, vin=self._vehicle_info.vin
 177                    ),
 178                ),
 179                EndpointDefinition(
 180                    name="notifications",
 181                    capable=True,
 182                    function=partial(
 183                        self._api.get_notifications, vin=self._vehicle_info.vin
 184                    ),
 185                ),
 186                EndpointDefinition(
 187                    name="status",
 188                    capable=getattr(
 189                        getattr(self._vehicle_info, "extended_capabilities", False),
 190                        "vehicle_status",
 191                        False,
 192                    ),
 193                    function=partial(
 194                        self._api.get_remote_status, vin=self._vehicle_info.vin
 195                    ),
 196                ),
 197                EndpointDefinition(
 198                    name="service_history",
 199                    capable=getattr(
 200                        getattr(self._vehicle_info, "features", False),
 201                        "service_history",
 202                        False,
 203                    ),
 204                    function=partial(
 205                        self._api.get_service_history, vin=self._vehicle_info.vin
 206                    ),
 207                ),
 208                EndpointDefinition(
 209                    name="climate_settings",
 210                    capable=climate_capable,
 211                    function=partial(
 212                        self._api.get_climate_settings, vin=self._vehicle_info.vin
 213                    ),
 214                    # Toyota selectively 500s on this endpoint for some
 215                    # accounts. See ha_toyota#291.
 216                    optional=True,
 217                ),
 218                EndpointDefinition(
 219                    name="climate_status",
 220                    capable=climate_capable,
 221                    function=partial(
 222                        self._api.get_climate_status, vin=self._vehicle_info.vin
 223                    ),
 224                    optional=True,
 225                ),
 226                EndpointDefinition(
 227                    name="trip_history",
 228                    capable=True,
 229                    function=partial(
 230                        self._api.get_trips,
 231                        vin=self._vehicle_info.vin,
 232                        from_date=(date.today() - timedelta(days=90)),  # noqa: DTZ011
 233                        to_date=date.today(),  # noqa: DTZ011
 234                        summary=True,
 235                        limit=1,
 236                        offset=0,
 237                        route=False,
 238                    ),
 239                ),
 240            ]
 241        else:
 242            raise ToyotaApiError(
 243                logger.error(
 244                    "The VIN (vehicle identification number) "
 245                    "required for the end point request could not be determined"
 246                )
 247            )
 248        self._endpoint_collect = [
 249            endpoint for endpoint in self._api_endpoints if endpoint.capable
 250        ]
 251        # Failures on optional endpoints from the most recent update().
 252        self._endpoint_errors: dict[str, Exception] = {}
 253
 254    def _climate_capable(self) -> bool:
 255        """Determine whether the vehicle supports remote climate control.
 256
 257        Some vehicles (notably BEVs and certain PHEVs) advertise climate
 258        support only through ``extended_capabilities`` rather than through
 259        ``features.climate_start_engine``. Checking both keeps compatibility
 260        with ICE/HEV vehicles while widening detection for BEV/PHEV accounts.
 261        See issue #192.
 262        """
 263        extended_capabilities = getattr(
 264            self._vehicle_info, "extended_capabilities", False
 265        )
 266        return bool(
 267            getattr(
 268                getattr(self._vehicle_info, "features", False),
 269                "climate_start_engine",
 270                False,
 271            )
 272            or any(
 273                getattr(extended_capabilities, attr, False)
 274                for attr in (
 275                    "climate_capable",
 276                    "econnect_climate_capable",
 277                    "remote_engine_start_stop",
 278                )
 279            )
 280        )
 281
 282    def _electric_capable(self) -> bool:
 283        """Determine whether the vehicle supports electric/EV status data.
 284
 285        Some vehicles (notably rebadged Suzuki DCM24-backend BEVs, e.g. the
 286        Urban Cruiser) report ``econnect_vehicle_status_capable=False`` even
 287        though they are genuine electric vehicles - Toyota's vehicle
 288        registry can be wrong about EV status for these platforms, the same
 289        way it is wrong about climate capability for some accounts (see
 290        ``_climate_capable``). Falling back to ``fuel_type in ("E", "I")``
 291        and the top-level ``ev_vehicle`` flag widens detection without
 292        affecting ICE/HEV vehicles, whose fuel_type is never ``"E"``/``"I"``.
 293        ``"I"`` is Toyota's fuel_type code for plug-in hybrids, which can
 294        report the same electric_status data as full EVs.
 295        See pytoyoda/ha_toyota#302, pytoyoda#312.
 296        """
 297        extended_capabilities = getattr(
 298            self._vehicle_info, "extended_capabilities", False
 299        )
 300        return bool(
 301            getattr(extended_capabilities, "econnect_vehicle_status_capable", False)
 302            or getattr(self._vehicle_info, "ev_vehicle", False)
 303            or getattr(self._vehicle_info, "fuel_type", None) in ("E", "I")
 304        )
 305
 306    async def update(
 307        self,
 308        skip: list[str] | None = None,
 309        only: list[str] | None = None,
 310    ) -> None:
 311        """Update the data for the vehicle.
 312
 313        Endpoint functions are awaited sequentially rather than in a single
 314        asyncio.gather. Toyota's API gateway appears to rate-limit on bursts
 315        of near-simultaneous requests: firing ~10 requests in the same event
 316        loop tick reliably trips a 429 with `{"description": "Unauthorized"}`
 317        response bodies, while the same requests serialised at poll cadence
 318        succeed cleanly. See pytoyoda/ha_toyota#282 for measurement evidence.
 319
 320        Args:
 321            skip: Endpoint names (matching EndpointDefinition.name values
 322                like "status", "telemetry", etc.) to skip this cycle.
 323                Skipped endpoints retain their previous _endpoint_data
 324                entry, so consumers continue to see the last-known value.
 325                Used by ha_toyota's smart-refresh strategy to skip
 326                /v1/global/remote/status when a separate POST/GET cycle
 327                handles it explicitly.
 328            only: Inverse of skip - if provided, ONLY these endpoint names
 329                will be fetched. Mutually exclusive with skip.
 330                Used by ha_toyota's smart-refresh strategy to update just
 331                /v1/global/remote/status after a wake POST without
 332                re-hitting the other endpoints that are already fresh.
 333
 334        Endpoints registered with ``optional=True`` do not raise on failure:
 335        the exception is recorded in ``_endpoint_errors[name]`` and the
 336        endpoint's ``_endpoint_data`` entry is cleared, so downstream getters
 337        return None for that cycle instead of stale data (note the asymmetry
 338        with ``skip``, which retains the previous data). Error records are
 339        kept until the endpoint is attempted again, so partial updates via
 340        ``skip``/``only`` leave other endpoints' diagnostics intact.
 341
 342        Returns:
 343            None
 344
 345        Raises:
 346            ValueError: If both skip and only are provided.
 347
 348        """
 349        if skip is not None and only is not None:
 350            msg = "update(): pass either skip or only, not both"
 351            raise ValueError(msg)
 352        skip_set = set(skip or [])
 353        only_set = set(only) if only is not None else None
 354        for endpoint in self._endpoint_collect:
 355            if only_set is not None and endpoint.name not in only_set:
 356                continue
 357            if endpoint.name in skip_set:
 358                continue
 359            # Clear only the record of endpoints attempted this cycle, so a
 360            # partial update (skip/only) does not erase the diagnostics of
 361            # endpoints it never retried.
 362            self._endpoint_errors.pop(endpoint.name, None)
 363            try:
 364                self._endpoint_data[endpoint.name] = await endpoint.function()
 365            except Exception as ex:
 366                if not endpoint.optional:
 367                    raise
 368                self._endpoint_errors[endpoint.name] = ex
 369                # Clear any stale payload from a previous successful cycle so
 370                # downstream getters return None instead of outdated data.
 371                self._endpoint_data.pop(endpoint.name, None)
 372                logger.warning(
 373                    "Optional endpoint '{}' failed and will be cleared this "
 374                    "cycle: {}: {}",
 375                    endpoint.name,
 376                    type(ex).__name__,
 377                    ex,
 378                )
 379
 380    @computed_field  # type: ignore[prop-decorator]
 381    @property
 382    def vin(self) -> str | None:
 383        """Return the vehicles VIN number.
 384
 385        Returns:
 386            Optional[str]: The vehicles VIN number
 387
 388        """
 389        return self._vehicle_info.vin
 390
 391    @computed_field  # type: ignore[prop-decorator]
 392    @property
 393    def alias(self) -> str | None:
 394        """Vehicle's alias.
 395
 396        Returns:
 397            Optional[str]: Nickname of vehicle
 398
 399        """
 400        return self._vehicle_info.nickname
 401
 402    @computed_field  # type: ignore[prop-decorator]
 403    @property
 404    def type(self) -> str | None:
 405        """Returns the "type" of vehicle.
 406
 407        Returns:
 408            Optional[str]: "fuel" if only fuel based
 409                "mildhybrid" if hybrid
 410                "phev" if plugin hybrid
 411                "ev" if full electric vehicle
 412
 413        """
 414        vehicle_type = VehicleType.from_vehicle_info(self._vehicle_info)
 415        return vehicle_type.name.lower()
 416
 417    @computed_field  # type: ignore[prop-decorator]
 418    @property
 419    def dashboard(self) -> Dashboard | None:
 420        """Returns the Vehicle dashboard.
 421
 422        The dashboard consists of items of information you would expect to
 423        find on the dashboard. i.e. Fuel Levels.
 424
 425        Returns:
 426            Optional[Dashboard]: A dashboard
 427
 428        """
 429        # Always returns a Dashboard object as we can always get the odometer value
 430        return Dashboard(
 431            self._endpoint_data.get("telemetry", None),
 432            self._endpoint_data.get("electric_status", None),
 433            self._endpoint_data.get("health_status", None),
 434            self._metric,
 435        )
 436
 437    @computed_field  # type: ignore[prop-decorator]
 438    @property
 439    def climate_settings(self) -> ClimateSettings | None:
 440        """Return the vehicle climate settings.
 441
 442        Returns:
 443            Optional[ClimateSettings]: A climate settings
 444
 445        """
 446        return ClimateSettings(self._endpoint_data.get("climate_settings", None))
 447
 448    @computed_field  # type: ignore[prop-decorator]
 449    @property
 450    def climate_status(self) -> ClimateStatus | None:
 451        """Return the vehicle climate status.
 452
 453        Returns:
 454            Optional[ClimateStatus]: A climate status
 455
 456        """
 457        return ClimateStatus(self._endpoint_data.get("climate_status", None))
 458
 459    @computed_field  # type: ignore[prop-decorator]
 460    @property
 461    def electric_status(self) -> ElectricStatus | None:
 462        """Returns the Electric Status of the vehicle.
 463
 464        Returns:
 465            Optional[ElectricStatus]: Electric Status
 466
 467        """
 468        return ElectricStatus(self._endpoint_data.get("electric_status", None))
 469
 470    async def refresh_electric_realtime_status(self) -> StatusModel:
 471        """Force update of electric realtime status.
 472
 473        This will drain the 12V battery of the vehicle if
 474        used to often!
 475
 476        Returns:
 477            StatusModel: A status response for the command.
 478
 479        """
 480        return await self._api.refresh_electric_realtime_status(self.vin)
 481
 482    async def refresh_status(self) -> RefreshStatusResponseModel:
 483        """Wake the vehicle and request a fresh /status cache populate.
 484
 485        Issues POST /v1/remote/status. Use sparingly:
 486        each call uses cellular airtime and a small amount of 12V battery.
 487        Returns when the gateway has accepted the wake request, NOT when
 488        the cache has actually been populated; the caller should poll
 489        /status afterwards (and check occurrence_date advancement) to
 490        verify the wake succeeded end-to-end.
 491
 492        Returns:
 493            RefreshStatusResponseModel: payload.return_code "000000"
 494                = wake accepted, anything else = vehicle does not
 495                support refresh-status (caller should disable further
 496                attempts for this VIN).
 497
 498        """
 499        return await self._api.refresh_vehicle_status(self.vin)
 500
 501    @computed_field  # type: ignore[prop-decorator]
 502    @property
 503    def location(self) -> Location | None:
 504        """Return the vehicles latest reported Location.
 505
 506        Returns:
 507            Optional[Location]: The latest location or None. If None vehicle car
 508                does not support providing location information.
 509                _Note_ an empty location object can be returned when the Vehicle
 510                supports location but none is currently available.
 511
 512        """
 513        return Location(self._endpoint_data.get("location", None))
 514
 515    @computed_field  # type: ignore[prop-decorator]
 516    @property
 517    def notifications(self) -> list[Notification] | None:
 518        r"""Returns a list of notifications for the vehicle.
 519
 520        Returns:
 521            Optional[list[Notification]]: A list of notifications for the vehicle,
 522                or None if not supported.
 523
 524        """
 525        if "notifications" in self._endpoint_data:
 526            ret: list[Notification] = []
 527            for p in self._endpoint_data["notifications"].payload:
 528                ret.extend(Notification(n) for n in p.notifications)
 529            return ret
 530
 531        return None
 532
 533    @computed_field  # type: ignore[prop-decorator]
 534    @property
 535    def service_history(self) -> list[ServiceHistory] | None:
 536        r"""Returns a list of service history entries for the vehicle.
 537
 538        Returns:
 539            Optional[list[ServiceHistory]]: A list of service history entries
 540                for the vehicle, or None if not supported.
 541
 542        """
 543        if "service_history" in self._endpoint_data:
 544            ret: list[ServiceHistory] = []
 545            payload = self._endpoint_data["service_history"].payload
 546            if not payload:
 547                return None
 548            ret.extend(
 549                ServiceHistory(service_history)
 550                for service_history in payload.service_histories
 551            )
 552            return ret
 553
 554        return None
 555
 556    def get_latest_service_history(self) -> ServiceHistory | None:
 557        r"""Return the latest service history entry for the vehicle.
 558
 559        Returns:
 560            Optional[ServiceHistory]: A service history entry for the vehicle,
 561                ordered by date and service_category. None if not supported or unknown.
 562
 563        """
 564        if self.service_history is not None:
 565            return max(
 566                self.service_history, key=lambda x: (x.service_date, x.service_category)
 567            )
 568        return None
 569
 570    @computed_field  # type: ignore[prop-decorator]
 571    @property
 572    def lock_status(self) -> LockStatus | None:
 573        """Returns the latest lock status of Doors & Windows.
 574
 575        Returns:
 576            Optional[LockStatus]: The latest lock status of Doors & Windows,
 577                or None if not supported.
 578
 579        """
 580        return LockStatus(self._endpoint_data.get("status", None))
 581
 582    @computed_field  # type: ignore[prop-decorator]
 583    @property
 584    def last_trip(self) -> Trip | None:
 585        """Returns the Vehicle last trip.
 586
 587        Returns:
 588            Optional[Trip]: The last trip
 589
 590        """
 591        ret = None
 592        if "trip_history" in self._endpoint_data:
 593            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
 594
 595        return None if ret is None else Trip(ret, self._metric)
 596
 597    @computed_field  # type: ignore[prop-decorator]
 598    @property
 599    def trip_history(self) -> list[Trip] | None:
 600        """Returns the Vehicle trips.
 601
 602        Returns:
 603            Optional[list[Trip]]: A list of trips
 604
 605        """
 606        if "trip_history" in self._endpoint_data:
 607            ret: list[Trip] = []
 608            payload = self._endpoint_data["trip_history"].payload
 609            ret.extend(Trip(t, self._metric) for t in payload.trips)
 610            return ret
 611
 612        return None
 613
 614    async def get_summary(
 615        self,
 616        from_date: date,
 617        to_date: date,
 618        summary_type: SummaryType = SummaryType.MONTHLY,
 619        limit: int | None = None,
 620        offset: int = 0,
 621    ) -> list[Summary]:
 622        """Return different summarys between the provided dates.
 623
 624        All but Daily can return a partial time range. For example
 625        if the summary_type is weekly and the date ranges selected
 626        include partial weeks these partial weeks will be returned.
 627        The dates contained in the summary will indicate the range
 628        of dates that made up the partial week.
 629
 630        Note: Weekly and yearly summaries lose a small amount of
 631        accuracy due to rounding issues.
 632
 633        Args:
 634            from_date (date, required): The inclusive from date to report summaries.
 635            to_date (date, required): The inclusive to date to report summaries.
 636            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
 637                Monthly by default.
 638            limit (int | None, optional): Maximum number of summaries to
 639                return. ``None`` (the default) returns every summary in the
 640                requested date range, preserving the previous behaviour.
 641            offset (int, optional): Number of summaries to skip from the
 642                start of the (chronologically ordered) result set before
 643                applying ``limit``. Defaults to ``0``.
 644
 645        Returns:
 646            list[Summary]: A list of summaries or empty list if not supported.
 647
 648        Raises:
 649            ValueError: ``limit`` is not ``None`` and is less than 1, or
 650                ``offset`` is negative.
 651
 652        """
 653        if limit is not None and limit < 1:
 654            msg = f"limit must be >= 1, got {limit}"
 655            raise ValueError(msg)
 656        if offset < 0:
 657            msg = f"offset must be >= 0, got {offset}"
 658            raise ValueError(msg)
 659
 660        to_date = min(to_date, date.today())  # noqa : DTZ011
 661
 662        # Summary information is always returned in the first response.
 663        # No need to check all the following pages
 664        resp = await self._api.get_trips(
 665            self.vin, from_date, to_date, summary=True, limit=1, offset=0
 666        )
 667        if resp.payload is None or len(resp.payload.summary) == 0:
 668            return []
 669
 670        # Convert to response
 671        if summary_type == SummaryType.DAILY:
 672            summaries = self._generate_daily_summaries(resp.payload.summary)
 673        elif summary_type == SummaryType.WEEKLY:
 674            summaries = self._generate_weekly_summaries(resp.payload.summary)
 675        elif summary_type == SummaryType.MONTHLY:
 676            summaries = self._generate_monthly_summaries(
 677                resp.payload.summary, from_date, to_date
 678            )
 679        elif summary_type == SummaryType.YEARLY:
 680            summaries = self._generate_yearly_summaries(resp.payload.summary, to_date)
 681        else:
 682            msg = "No such SummaryType"
 683            raise AssertionError(msg)
 684
 685        if offset:
 686            summaries = summaries[offset:]
 687        if limit is not None:
 688            summaries = summaries[:limit]
 689        return summaries
 690
 691    async def get_current_day_summary(self) -> Summary | None:
 692        """Return a summary for the current day.
 693
 694        Returns:
 695            Optional[Summary]: A summary or None if not supported.
 696
 697        """
 698        summary = await self.get_summary(
 699            from_date=Arrow.now().date(),
 700            to_date=Arrow.now().date(),
 701            summary_type=SummaryType.DAILY,
 702        )
 703        min_no_of_summaries_required_for_calculation = 2
 704        if len(summary) < min_no_of_summaries_required_for_calculation:
 705            logger.info("Not enough summaries for calculation.")
 706        return summary[0] if len(summary) > 0 else None
 707
 708    async def get_current_week_summary(self) -> Summary | None:
 709        """Return a summary for the current week.
 710
 711        Returns:
 712            Optional[Summary]: A summary or None if not supported.
 713
 714        """
 715        summary = await self.get_summary(
 716            from_date=Arrow.now().floor("week").date(),
 717            to_date=Arrow.now().date(),
 718            summary_type=SummaryType.WEEKLY,
 719        )
 720        min_no_of_summaries_required_for_calculation = 2
 721        if len(summary) < min_no_of_summaries_required_for_calculation:
 722            logger.info("Not enough summaries for calculation.")
 723        return summary[0] if len(summary) > 0 else None
 724
 725    async def get_current_month_summary(self) -> Summary | None:
 726        """Return a summary for the current month.
 727
 728        Returns:
 729            Optional[Summary]: A summary or None if not supported.
 730
 731        """
 732        summary = await self.get_summary(
 733            from_date=Arrow.now().floor("month").date(),
 734            to_date=Arrow.now().date(),
 735            summary_type=SummaryType.MONTHLY,
 736        )
 737        min_no_of_summaries_required_for_calculation = 2
 738        if len(summary) < min_no_of_summaries_required_for_calculation:
 739            logger.info("Not enough summaries for calculation.")
 740        return summary[0] if len(summary) > 0 else None
 741
 742    async def get_current_year_summary(self) -> Summary | None:
 743        """Return a summary for the current year.
 744
 745        Returns:
 746            Optional[Summary]: A summary or None if not supported.
 747
 748        """
 749        summary = await self.get_summary(
 750            from_date=Arrow.now().floor("year").date(),
 751            to_date=Arrow.now().date(),
 752            summary_type=SummaryType.YEARLY,
 753        )
 754        min_no_of_summaries_required_for_calculation = 2
 755        if len(summary) < min_no_of_summaries_required_for_calculation:
 756            logger.info("Not enough summaries for calculation.")
 757        return summary[0] if len(summary) > 0 else None
 758
 759    async def get_trips(
 760        self,
 761        from_date: date,
 762        to_date: date,
 763        full_route: bool = False,  # noqa : FBT001, FBT002
 764        limit: int | None = None,
 765        offset: int = 0,
 766    ) -> list[Trip] | None:
 767        """Return information on trips made between the provided dates.
 768
 769        Args:
 770            from_date (date, required): The inclusive from date
 771            to_date (date, required): The inclusive to date
 772            full_route (bool, optional): Provide the full route
 773                information for each trip. Costs one extra API call per
 774                trip, as routes are only served by the trip detail endpoint.
 775            limit (int | None, optional): Maximum total number of trips
 776                to return to the caller. ``None`` (the default) returns every
 777                trip in the requested date range, preserving the previous
 778                behaviour of paginating through all pages. If set, at most
 779                ``limit`` trips are returned, fetching only as many pages
 780                from the API as necessary.
 781            offset (int, optional): Number of trips to skip from the
 782                start of the (most-recent-first) result set before applying
 783                ``limit``. Defaults to ``0``.
 784
 785        Returns:
 786            Optional[list[Trip]]: A list of trips (bounded by ``limit`` when
 787            given) or None if not supported.
 788
 789        Raises:
 790            ValueError: ``limit`` is not ``None`` and is less than 1, or
 791                ``offset`` is negative.
 792
 793        """
 794        if limit is not None and limit < 1:
 795            msg = f"limit must be >= 1, got {limit}"
 796            raise ValueError(msg)
 797        if offset < 0:
 798            msg = f"offset must be >= 0, got {offset}"
 799            raise ValueError(msg)
 800
 801        page_size = 5
 802        collected: list[_TripModel] = []
 803        skipped = 0
 804        api_offset = 0
 805        while True:
 806            # Once limit is known we can stop as soon as we've collected
 807            # enough trips beyond the requested offset - no need to keep
 808            # paginating through the entire remaining result set.
 809            resp = await self._api.get_trips(
 810                self.vin,
 811                from_date,
 812                to_date,
 813                summary=False,
 814                limit=page_size,
 815                offset=api_offset,
 816            )
 817            if resp.payload is None:
 818                break
 819
 820            for t in resp.payload.trips or []:
 821                if skipped < offset:
 822                    skipped += 1
 823                    continue
 824                collected.append(t)
 825                if limit is not None and len(collected) >= limit:
 826                    break
 827
 828            api_offset = resp.payload.metadata.pagination.next_offset
 829            if api_offset is None or (limit is not None and len(collected) >= limit):
 830                break
 831
 832        if full_route:
 833            await self._load_routes(collected)
 834        return [Trip(t, self._metric) for t in collected]
 835
 836    async def get_last_trip(self, offset: int = 0) -> Trip | None:
 837        """Return information on the Nth-most-recent trip.
 838
 839        Args:
 840            offset (int, optional): 0-based position in the
 841                most-recent-first trip history. ``0`` (the default) returns
 842                the most recent trip, ``1`` returns the second most recent
 843                trip, and so on.
 844
 845        Returns:
 846            Optional[Trip]: A trip model, or None if not supported or if
 847            there is no trip at the requested ``offset``.
 848
 849        Raises:
 850            ValueError: ``offset`` is negative.
 851
 852        """
 853        trips = await self.get_trips(
 854            date.today() - timedelta(days=90),  # noqa : DTZ011
 855            date.today(),  # noqa : DTZ011
 856            full_route=False,
 857            limit=1,
 858            offset=offset,
 859        )
 860
 861        if not trips:
 862            return None
 863        return trips[0]
 864
 865    async def get_recent_trips(
 866        self,
 867        limit: int = 5,
 868        with_route: bool = False,  # noqa: FBT001, FBT002
 869        from_date: date | None = None,
 870        to_date: date | None = None,
 871        offset: int = 0,
 872    ) -> list[Trip]:
 873        """Fetch a single page of trips, most-recent-first (one HTTP call).
 874
 875        Args:
 876            limit: Page size, 1..50.
 877            with_route: Include per-trip route coordinates (one extra API call
 878                per trip).
 879            from_date: Lower bound, inclusive. Defaults to today minus 90 days.
 880            to_date: Upper bound, inclusive. Defaults to today.
 881            offset: 0-based offset into the most-recent-first result set.
 882
 883        Returns:
 884            List of Trip models, empty if no trips match.
 885
 886        Raises:
 887            ValueError: ``limit`` outside 1..50, or ``offset`` negative.
 888
 889        """
 890        if not 1 <= limit <= 50:  # noqa: PLR2004
 891            msg = f"limit must be between 1 and 50, got {limit}"
 892            raise ValueError(msg)
 893        if offset < 0:
 894            msg = f"offset must be >= 0, got {offset}"
 895            raise ValueError(msg)
 896        if from_date is None:
 897            from_date = date.today() - timedelta(days=90)  # noqa: DTZ011
 898        if to_date is None:
 899            to_date = date.today()  # noqa: DTZ011
 900
 901        resp = await self._api.get_trips(
 902            self.vin,
 903            from_date,
 904            to_date,
 905            summary=False,
 906            limit=limit,
 907            offset=offset,
 908        )
 909
 910        if resp.payload is None or not resp.payload.trips:
 911            return []
 912
 913        if with_route:
 914            await self._load_routes(resp.payload.trips)
 915        return [Trip(t, self._metric) for t in resp.payload.trips]
 916
 917    async def _load_routes(self, trips: list[_TripModel]) -> None:
 918        """Fill in the route of each trip from the trip detail endpoint.
 919
 920        The trip list endpoint does not return routes; they are only available
 921        per trip, so this costs one HTTP call per trip.
 922        """
 923        semaphore = asyncio.Semaphore(_ROUTE_FETCH_CONCURRENCY)
 924
 925        async def load(trip: _TripModel) -> None:
 926            if trip.id is None:
 927                return
 928            async with semaphore:
 929                resp = await self._api.get_trip_details(self.vin, trip.id)
 930            if resp.payload is not None:
 931                trip.route = resp.payload.route
 932
 933        await asyncio.gather(*(load(t) for t in trips))
 934
 935    async def refresh_climate_status(self) -> StatusModel:
 936        """Force update of climate status.
 937
 938        Returns:
 939            StatusModel: A status response for the command.
 940
 941        """
 942        return await self._api.refresh_climate_status(self.vin)
 943
 944    async def set_climate(
 945        self, request: V2RemoteClimateControlRequestModel
 946    ) -> RemoteClimateControlResponseModel:
 947        """Start or stop remote climate control (POST /v2/remote/climate-control).
 948
 949        A ``start`` request carries the full desired settings (temperature +
 950        heating/seat options + ``save_settings``); a ``stop`` is just
 951        ``command="stop"``. Acknowledgement is ``response.payload.return_code ==
 952        "000000"``; confirm the actual on/off state via the climate-status read.
 953
 954        Args:
 955            request: The V2 climate-control request body.
 956
 957        Returns:
 958            RemoteClimateControlResponseModel: The command acknowledgement.
 959
 960        """
 961        return await self._api.send_climate_control_command(self.vin, request)
 962
 963    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
 964        """Send remote command to the vehicle.
 965
 966        Args:
 967            command (CommandType): The remote command model
 968            beeps (int): Amount of beeps for commands that support it
 969
 970        Returns:
 971            StatusModel: A status response for the command.
 972
 973        """
 974        return await self._api.send_command(self.vin, command=command, beeps=beeps)
 975
 976    async def send_next_charging_command(
 977        self, command: NextChargeSettings
 978    ) -> ElectricCommandResponseModel:
 979        """Send the next command to the vehicle.
 980
 981        Args:
 982            command: NextChargeSettings command to send
 983
 984        Returns:
 985            Model containing status of the command request
 986
 987        """
 988        return await self._api.send_next_charging_command(self.vin, command=command)
 989
 990    #
 991    # More get functionality depending on what we find
 992    #
 993
 994    async def set_alias(
 995        self,
 996        value: bool,  # noqa : FBT001
 997    ) -> bool:
 998        """Set the alias for the vehicle.
 999
1000        Args:
1001            value: The alias value to set for the vehicle.
1002
1003        Returns:
1004            bool: Indicator if value is set
1005
1006        """
1007        return value
1008
1009    #
1010    # More set functionality depending on what we find
1011    #
1012
1013    def _dump_all(self) -> dict[str, Any]:
1014        """Dump data from all endpoints for debugging and further work."""
1015        dump: [str, Any] = {
1016            "vehicle_info": json.loads(self._vehicle_info.model_dump_json())
1017        }
1018        for name, data in self._endpoint_data.items():
1019            dump[name] = json.loads(data.model_dump_json())
1020
1021        return censor_all(copy.deepcopy(dump))
1022
1023    def _generate_daily_summaries(
1024        self, summary: list[_SummaryItemModel]
1025    ) -> list[Summary]:
1026        summary.sort(key=attrgetter("year", "month"))
1027        # Skip histograms with summary=None - a hollow Summary crashes
1028        # downstream when sensors read its properties (see #278).
1029        return [
1030            Summary(
1031                histogram.summary,
1032                self._metric,
1033                Arrow(histogram.year, histogram.month, histogram.day).date(),
1034                Arrow(histogram.year, histogram.month, histogram.day).date(),
1035                histogram.hdc,
1036                [histogram.scores.global_]
1037                if histogram.scores and histogram.scores.global_ is not None
1038                else None,
1039            )
1040            for month in summary
1041            for histogram in sorted(month.histograms, key=attrgetter("day"))
1042            if histogram.summary is not None
1043        ]
1044
1045    def _generate_weekly_summaries(
1046        self, summary: list[_SummaryItemModel]
1047    ) -> list[Summary]:
1048        ret: list[Summary] = []
1049        summary.sort(key=attrgetter("year", "month"))
1050
1051        # Flatten the list of histograms
1052        histograms = [histogram for month in summary for histogram in month.histograms]
1053        histograms.sort(key=lambda h: date(day=h.day, month=h.month, year=h.year))
1054
1055        # Group histograms by week
1056        for _, week_histograms_iter in groupby(
1057            histograms, key=lambda h: Arrow(h.year, h.month, h.day).span("week")[0]
1058        ):
1059            week_histograms = list(week_histograms_iter)
1060            build_hdc = copy.copy(week_histograms[0].hdc)
1061            build_summary = copy.copy(week_histograms[0].summary)
1062            build_scores: list[int] = [
1063                h.scores.global_
1064                for h in week_histograms
1065                if h.scores and h.scores.global_ is not None
1066            ]
1067            start_date = Arrow(
1068                week_histograms[0].year,
1069                week_histograms[0].month,
1070                week_histograms[0].day,
1071            )
1072
1073            for histogram in week_histograms[1:]:
1074                # ``add_with_none`` returns the sum, so we must capture it;
1075                # without the assignment ``build_hdc`` would stay at the
1076                # first histogram's hdc (or ``None`` if that was None).
1077                build_hdc = add_with_none(build_hdc, histogram.hdc)
1078                # histogram.summary (and the seed build_summary) may be None on
1079                # days where the Toyota API returned a partial payload. Seed with
1080                # the first non-None summary we see, then accumulate.
1081                if histogram.summary is None:
1082                    continue
1083                if build_summary is None:
1084                    build_summary = copy.copy(histogram.summary)
1085                else:
1086                    build_summary += histogram.summary
1087
1088            end_date = Arrow(
1089                week_histograms[-1].year,
1090                week_histograms[-1].month,
1091                week_histograms[-1].day,
1092            )
1093            # Skip weeks where every histogram.summary was None - a hollow
1094            # Summary crashes downstream when sensors read its properties.
1095            if build_summary is None:
1096                continue
1097            ret.append(
1098                Summary(
1099                    build_summary,
1100                    self._metric,
1101                    start_date.date(),
1102                    end_date.date(),
1103                    build_hdc,
1104                    build_scores or None,
1105                )
1106            )
1107
1108        return ret
1109
1110    def _generate_monthly_summaries(
1111        self, summary: list[_SummaryItemModel], from_date: date, to_date: date
1112    ) -> list[Summary]:
1113        # Convert all the monthly responses from the payload to a summary response
1114        ret: list[Summary] = []
1115        summary.sort(key=attrgetter("year", "month"))
1116        for month in summary:
1117            # Skip months with summary=None - a hollow Summary crashes
1118            # downstream when sensors read its properties (see #278).
1119            if month.summary is None:
1120                continue
1121            month_start = Arrow(month.year, month.month, 1).date()
1122            month_end = (
1123                Arrow(month.year, month.month, 1).shift(months=1).shift(days=-1).date()
1124            )
1125
1126            ret.append(
1127                Summary(
1128                    month.summary,
1129                    self._metric,
1130                    # The data might not include an entire month
1131                    # so update start and end dates.
1132                    max(month_start, from_date),
1133                    min(month_end, to_date),
1134                    month.hdc,
1135                    [month.scores.global_]
1136                    if month.scores and month.scores.global_ is not None
1137                    else None,
1138                )
1139            )
1140
1141        return ret
1142
1143    def _generate_yearly_summaries(
1144        self, summary: list[_SummaryItemModel], to_date: date
1145    ) -> list[Summary]:
1146        summary.sort(key=attrgetter("year", "month"))
1147        ret: list[Summary] = []
1148        build_hdc = copy.copy(summary[0].hdc)
1149        build_summary = copy.copy(summary[0].summary)
1150        build_scores: list[int] = (
1151            [summary[0].scores.global_]
1152            if summary[0].scores and summary[0].scores.global_ is not None
1153            else []
1154        )
1155        start_date = date(day=1, month=summary[0].month, year=summary[0].year)
1156
1157        if len(summary) == 1:
1158            if build_summary is not None:
1159                ret.append(
1160                    Summary(
1161                        build_summary,
1162                        self._metric,
1163                        start_date,
1164                        to_date,
1165                        build_hdc,
1166                        build_scores or None,
1167                    )
1168                )
1169        else:
1170            for month, next_month in zip(
1171                summary[1:], [*summary[2:], None], strict=False
1172            ):
1173                summary_month = date(day=1, month=month.month, year=month.year)
1174                # ``add_with_none`` returns the sum; capture it or ``build_hdc``
1175                # stays at the year's first month's hdc.
1176                build_hdc = add_with_none(build_hdc, month.hdc)
1177                # month.summary (and the seed build_summary) may be None when
1178                # the Toyota API returned partial data.
1179                if month.summary is not None:
1180                    if build_summary is None:
1181                        build_summary = copy.copy(month.summary)
1182                    else:
1183                        build_summary += month.summary
1184                if month.scores and month.scores.global_ is not None:
1185                    build_scores.append(month.scores.global_)
1186
1187                if next_month is None or next_month.year != month.year:
1188                    end_date = min(
1189                        to_date, date(day=31, month=12, year=summary_month.year)
1190                    )
1191                    # Skip years where every month.summary was None - a hollow
1192                    # Summary crashes downstream when sensors read its properties.
1193                    if build_summary is not None:
1194                        ret.append(
1195                            Summary(
1196                                build_summary,
1197                                self._metric,
1198                                start_date,
1199                                end_date,
1200                                build_hdc,
1201                                build_scores or None,
1202                            )
1203                        )
1204                    if next_month:
1205                        start_date = date(
1206                            day=1, month=next_month.month, year=next_month.year
1207                        )
1208                        build_hdc = copy.copy(next_month.hdc)
1209                        build_summary = copy.copy(next_month.summary)
1210                        build_scores = (
1211                            [next_month.scores.global_]
1212                            if next_month.scores
1213                            and next_month.scores.global_ is not None
1214                            else []
1215                        )
1216
1217        return ret

Vehicle data representation.

async def update( self, skip: list[str] | None = None, only: list[str] | None = None) -> None:
306    async def update(
307        self,
308        skip: list[str] | None = None,
309        only: list[str] | None = None,
310    ) -> None:
311        """Update the data for the vehicle.
312
313        Endpoint functions are awaited sequentially rather than in a single
314        asyncio.gather. Toyota's API gateway appears to rate-limit on bursts
315        of near-simultaneous requests: firing ~10 requests in the same event
316        loop tick reliably trips a 429 with `{"description": "Unauthorized"}`
317        response bodies, while the same requests serialised at poll cadence
318        succeed cleanly. See pytoyoda/ha_toyota#282 for measurement evidence.
319
320        Args:
321            skip: Endpoint names (matching EndpointDefinition.name values
322                like "status", "telemetry", etc.) to skip this cycle.
323                Skipped endpoints retain their previous _endpoint_data
324                entry, so consumers continue to see the last-known value.
325                Used by ha_toyota's smart-refresh strategy to skip
326                /v1/global/remote/status when a separate POST/GET cycle
327                handles it explicitly.
328            only: Inverse of skip - if provided, ONLY these endpoint names
329                will be fetched. Mutually exclusive with skip.
330                Used by ha_toyota's smart-refresh strategy to update just
331                /v1/global/remote/status after a wake POST without
332                re-hitting the other endpoints that are already fresh.
333
334        Endpoints registered with ``optional=True`` do not raise on failure:
335        the exception is recorded in ``_endpoint_errors[name]`` and the
336        endpoint's ``_endpoint_data`` entry is cleared, so downstream getters
337        return None for that cycle instead of stale data (note the asymmetry
338        with ``skip``, which retains the previous data). Error records are
339        kept until the endpoint is attempted again, so partial updates via
340        ``skip``/``only`` leave other endpoints' diagnostics intact.
341
342        Returns:
343            None
344
345        Raises:
346            ValueError: If both skip and only are provided.
347
348        """
349        if skip is not None and only is not None:
350            msg = "update(): pass either skip or only, not both"
351            raise ValueError(msg)
352        skip_set = set(skip or [])
353        only_set = set(only) if only is not None else None
354        for endpoint in self._endpoint_collect:
355            if only_set is not None and endpoint.name not in only_set:
356                continue
357            if endpoint.name in skip_set:
358                continue
359            # Clear only the record of endpoints attempted this cycle, so a
360            # partial update (skip/only) does not erase the diagnostics of
361            # endpoints it never retried.
362            self._endpoint_errors.pop(endpoint.name, None)
363            try:
364                self._endpoint_data[endpoint.name] = await endpoint.function()
365            except Exception as ex:
366                if not endpoint.optional:
367                    raise
368                self._endpoint_errors[endpoint.name] = ex
369                # Clear any stale payload from a previous successful cycle so
370                # downstream getters return None instead of outdated data.
371                self._endpoint_data.pop(endpoint.name, None)
372                logger.warning(
373                    "Optional endpoint '{}' failed and will be cleared this "
374                    "cycle: {}: {}",
375                    endpoint.name,
376                    type(ex).__name__,
377                    ex,
378                )

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.

Endpoints registered with optional=True do not raise on failure: the exception is recorded in _endpoint_errors[name] and the endpoint's _endpoint_data entry is cleared, so downstream getters return None for that cycle instead of stale data (note the asymmetry with skip, which retains the previous data). Error records are kept until the endpoint is attempted again, so partial updates via skip/only leave other endpoints' diagnostics intact.

Returns:

None

Raises:
  • ValueError: If both skip and only are provided.
vin: str | None
380    @computed_field  # type: ignore[prop-decorator]
381    @property
382    def vin(self) -> str | None:
383        """Return the vehicles VIN number.
384
385        Returns:
386            Optional[str]: The vehicles VIN number
387
388        """
389        return self._vehicle_info.vin

Return the vehicles VIN number.

Returns:

Optional[str]: The vehicles VIN number

alias: str | None
391    @computed_field  # type: ignore[prop-decorator]
392    @property
393    def alias(self) -> str | None:
394        """Vehicle's alias.
395
396        Returns:
397            Optional[str]: Nickname of vehicle
398
399        """
400        return self._vehicle_info.nickname

Vehicle's alias.

Returns:

Optional[str]: Nickname of vehicle

type: str | None
402    @computed_field  # type: ignore[prop-decorator]
403    @property
404    def type(self) -> str | None:
405        """Returns the "type" of vehicle.
406
407        Returns:
408            Optional[str]: "fuel" if only fuel based
409                "mildhybrid" if hybrid
410                "phev" if plugin hybrid
411                "ev" if full electric vehicle
412
413        """
414        vehicle_type = VehicleType.from_vehicle_info(self._vehicle_info)
415        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
417    @computed_field  # type: ignore[prop-decorator]
418    @property
419    def dashboard(self) -> Dashboard | None:
420        """Returns the Vehicle dashboard.
421
422        The dashboard consists of items of information you would expect to
423        find on the dashboard. i.e. Fuel Levels.
424
425        Returns:
426            Optional[Dashboard]: A dashboard
427
428        """
429        # Always returns a Dashboard object as we can always get the odometer value
430        return Dashboard(
431            self._endpoint_data.get("telemetry", None),
432            self._endpoint_data.get("electric_status", None),
433            self._endpoint_data.get("health_status", None),
434            self._metric,
435        )

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
437    @computed_field  # type: ignore[prop-decorator]
438    @property
439    def climate_settings(self) -> ClimateSettings | None:
440        """Return the vehicle climate settings.
441
442        Returns:
443            Optional[ClimateSettings]: A climate settings
444
445        """
446        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
448    @computed_field  # type: ignore[prop-decorator]
449    @property
450    def climate_status(self) -> ClimateStatus | None:
451        """Return the vehicle climate status.
452
453        Returns:
454            Optional[ClimateStatus]: A climate status
455
456        """
457        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
459    @computed_field  # type: ignore[prop-decorator]
460    @property
461    def electric_status(self) -> ElectricStatus | None:
462        """Returns the Electric Status of the vehicle.
463
464        Returns:
465            Optional[ElectricStatus]: Electric Status
466
467        """
468        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:
470    async def refresh_electric_realtime_status(self) -> StatusModel:
471        """Force update of electric realtime status.
472
473        This will drain the 12V battery of the vehicle if
474        used to often!
475
476        Returns:
477            StatusModel: A status response for the command.
478
479        """
480        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:
482    async def refresh_status(self) -> RefreshStatusResponseModel:
483        """Wake the vehicle and request a fresh /status cache populate.
484
485        Issues POST /v1/remote/status. Use sparingly:
486        each call uses cellular airtime and a small amount of 12V battery.
487        Returns when the gateway has accepted the wake request, NOT when
488        the cache has actually been populated; the caller should poll
489        /status afterwards (and check occurrence_date advancement) to
490        verify the wake succeeded end-to-end.
491
492        Returns:
493            RefreshStatusResponseModel: payload.return_code "000000"
494                = wake accepted, anything else = vehicle does not
495                support refresh-status (caller should disable further
496                attempts for this VIN).
497
498        """
499        return await self._api.refresh_vehicle_status(self.vin)

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

Issues POST /v1/remote/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
501    @computed_field  # type: ignore[prop-decorator]
502    @property
503    def location(self) -> Location | None:
504        """Return the vehicles latest reported Location.
505
506        Returns:
507            Optional[Location]: The latest location or None. If None vehicle car
508                does not support providing location information.
509                _Note_ an empty location object can be returned when the Vehicle
510                supports location but none is currently available.
511
512        """
513        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
515    @computed_field  # type: ignore[prop-decorator]
516    @property
517    def notifications(self) -> list[Notification] | None:
518        r"""Returns a list of notifications for the vehicle.
519
520        Returns:
521            Optional[list[Notification]]: A list of notifications for the vehicle,
522                or None if not supported.
523
524        """
525        if "notifications" in self._endpoint_data:
526            ret: list[Notification] = []
527            for p in self._endpoint_data["notifications"].payload:
528                ret.extend(Notification(n) for n in p.notifications)
529            return ret
530
531        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
533    @computed_field  # type: ignore[prop-decorator]
534    @property
535    def service_history(self) -> list[ServiceHistory] | None:
536        r"""Returns a list of service history entries for the vehicle.
537
538        Returns:
539            Optional[list[ServiceHistory]]: A list of service history entries
540                for the vehicle, or None if not supported.
541
542        """
543        if "service_history" in self._endpoint_data:
544            ret: list[ServiceHistory] = []
545            payload = self._endpoint_data["service_history"].payload
546            if not payload:
547                return None
548            ret.extend(
549                ServiceHistory(service_history)
550                for service_history in payload.service_histories
551            )
552            return ret
553
554        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:
556    def get_latest_service_history(self) -> ServiceHistory | None:
557        r"""Return the latest service history entry for the vehicle.
558
559        Returns:
560            Optional[ServiceHistory]: A service history entry for the vehicle,
561                ordered by date and service_category. None if not supported or unknown.
562
563        """
564        if self.service_history is not None:
565            return max(
566                self.service_history, key=lambda x: (x.service_date, x.service_category)
567            )
568        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
570    @computed_field  # type: ignore[prop-decorator]
571    @property
572    def lock_status(self) -> LockStatus | None:
573        """Returns the latest lock status of Doors & Windows.
574
575        Returns:
576            Optional[LockStatus]: The latest lock status of Doors & Windows,
577                or None if not supported.
578
579        """
580        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
582    @computed_field  # type: ignore[prop-decorator]
583    @property
584    def last_trip(self) -> Trip | None:
585        """Returns the Vehicle last trip.
586
587        Returns:
588            Optional[Trip]: The last trip
589
590        """
591        ret = None
592        if "trip_history" in self._endpoint_data:
593            ret = next(iter(self._endpoint_data["trip_history"].payload.trips), None)
594
595        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
597    @computed_field  # type: ignore[prop-decorator]
598    @property
599    def trip_history(self) -> list[Trip] | None:
600        """Returns the Vehicle trips.
601
602        Returns:
603            Optional[list[Trip]]: A list of trips
604
605        """
606        if "trip_history" in self._endpoint_data:
607            ret: list[Trip] = []
608            payload = self._endpoint_data["trip_history"].payload
609            ret.extend(Trip(t, self._metric) for t in payload.trips)
610            return ret
611
612        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>, limit: int | None = None, offset: int = 0) -> list[pytoyoda.models.summary.Summary]:
614    async def get_summary(
615        self,
616        from_date: date,
617        to_date: date,
618        summary_type: SummaryType = SummaryType.MONTHLY,
619        limit: int | None = None,
620        offset: int = 0,
621    ) -> list[Summary]:
622        """Return different summarys between the provided dates.
623
624        All but Daily can return a partial time range. For example
625        if the summary_type is weekly and the date ranges selected
626        include partial weeks these partial weeks will be returned.
627        The dates contained in the summary will indicate the range
628        of dates that made up the partial week.
629
630        Note: Weekly and yearly summaries lose a small amount of
631        accuracy due to rounding issues.
632
633        Args:
634            from_date (date, required): The inclusive from date to report summaries.
635            to_date (date, required): The inclusive to date to report summaries.
636            summary_type (SummaryType, optional): Daily, Monthly or Yearly summary.
637                Monthly by default.
638            limit (int | None, optional): Maximum number of summaries to
639                return. ``None`` (the default) returns every summary in the
640                requested date range, preserving the previous behaviour.
641            offset (int, optional): Number of summaries to skip from the
642                start of the (chronologically ordered) result set before
643                applying ``limit``. Defaults to ``0``.
644
645        Returns:
646            list[Summary]: A list of summaries or empty list if not supported.
647
648        Raises:
649            ValueError: ``limit`` is not ``None`` and is less than 1, or
650                ``offset`` is negative.
651
652        """
653        if limit is not None and limit < 1:
654            msg = f"limit must be >= 1, got {limit}"
655            raise ValueError(msg)
656        if offset < 0:
657            msg = f"offset must be >= 0, got {offset}"
658            raise ValueError(msg)
659
660        to_date = min(to_date, date.today())  # noqa : DTZ011
661
662        # Summary information is always returned in the first response.
663        # No need to check all the following pages
664        resp = await self._api.get_trips(
665            self.vin, from_date, to_date, summary=True, limit=1, offset=0
666        )
667        if resp.payload is None or len(resp.payload.summary) == 0:
668            return []
669
670        # Convert to response
671        if summary_type == SummaryType.DAILY:
672            summaries = self._generate_daily_summaries(resp.payload.summary)
673        elif summary_type == SummaryType.WEEKLY:
674            summaries = self._generate_weekly_summaries(resp.payload.summary)
675        elif summary_type == SummaryType.MONTHLY:
676            summaries = self._generate_monthly_summaries(
677                resp.payload.summary, from_date, to_date
678            )
679        elif summary_type == SummaryType.YEARLY:
680            summaries = self._generate_yearly_summaries(resp.payload.summary, to_date)
681        else:
682            msg = "No such SummaryType"
683            raise AssertionError(msg)
684
685        if offset:
686            summaries = summaries[offset:]
687        if limit is not None:
688            summaries = summaries[:limit]
689        return summaries

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.
  • limit (int | None, optional): Maximum number of summaries to return. None (the default) returns every summary in the requested date range, preserving the previous behaviour.
  • offset (int, optional): Number of summaries to skip from the start of the (chronologically ordered) result set before applying limit. Defaults to 0.
Returns:

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

Raises:
  • ValueError: limit is not None and is less than 1, or offset is negative.
async def get_current_day_summary(self) -> pytoyoda.models.summary.Summary | None:
691    async def get_current_day_summary(self) -> Summary | None:
692        """Return a summary for the current day.
693
694        Returns:
695            Optional[Summary]: A summary or None if not supported.
696
697        """
698        summary = await self.get_summary(
699            from_date=Arrow.now().date(),
700            to_date=Arrow.now().date(),
701            summary_type=SummaryType.DAILY,
702        )
703        min_no_of_summaries_required_for_calculation = 2
704        if len(summary) < min_no_of_summaries_required_for_calculation:
705            logger.info("Not enough summaries for calculation.")
706        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:
708    async def get_current_week_summary(self) -> Summary | None:
709        """Return a summary for the current week.
710
711        Returns:
712            Optional[Summary]: A summary or None if not supported.
713
714        """
715        summary = await self.get_summary(
716            from_date=Arrow.now().floor("week").date(),
717            to_date=Arrow.now().date(),
718            summary_type=SummaryType.WEEKLY,
719        )
720        min_no_of_summaries_required_for_calculation = 2
721        if len(summary) < min_no_of_summaries_required_for_calculation:
722            logger.info("Not enough summaries for calculation.")
723        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:
725    async def get_current_month_summary(self) -> Summary | None:
726        """Return a summary for the current month.
727
728        Returns:
729            Optional[Summary]: A summary or None if not supported.
730
731        """
732        summary = await self.get_summary(
733            from_date=Arrow.now().floor("month").date(),
734            to_date=Arrow.now().date(),
735            summary_type=SummaryType.MONTHLY,
736        )
737        min_no_of_summaries_required_for_calculation = 2
738        if len(summary) < min_no_of_summaries_required_for_calculation:
739            logger.info("Not enough summaries for calculation.")
740        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:
742    async def get_current_year_summary(self) -> Summary | None:
743        """Return a summary for the current year.
744
745        Returns:
746            Optional[Summary]: A summary or None if not supported.
747
748        """
749        summary = await self.get_summary(
750            from_date=Arrow.now().floor("year").date(),
751            to_date=Arrow.now().date(),
752            summary_type=SummaryType.YEARLY,
753        )
754        min_no_of_summaries_required_for_calculation = 2
755        if len(summary) < min_no_of_summaries_required_for_calculation:
756            logger.info("Not enough summaries for calculation.")
757        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, limit: int | None = None, offset: int = 0) -> list[pytoyoda.models.trips.Trip] | None:
759    async def get_trips(
760        self,
761        from_date: date,
762        to_date: date,
763        full_route: bool = False,  # noqa : FBT001, FBT002
764        limit: int | None = None,
765        offset: int = 0,
766    ) -> list[Trip] | None:
767        """Return information on trips made between the provided dates.
768
769        Args:
770            from_date (date, required): The inclusive from date
771            to_date (date, required): The inclusive to date
772            full_route (bool, optional): Provide the full route
773                information for each trip. Costs one extra API call per
774                trip, as routes are only served by the trip detail endpoint.
775            limit (int | None, optional): Maximum total number of trips
776                to return to the caller. ``None`` (the default) returns every
777                trip in the requested date range, preserving the previous
778                behaviour of paginating through all pages. If set, at most
779                ``limit`` trips are returned, fetching only as many pages
780                from the API as necessary.
781            offset (int, optional): Number of trips to skip from the
782                start of the (most-recent-first) result set before applying
783                ``limit``. Defaults to ``0``.
784
785        Returns:
786            Optional[list[Trip]]: A list of trips (bounded by ``limit`` when
787            given) or None if not supported.
788
789        Raises:
790            ValueError: ``limit`` is not ``None`` and is less than 1, or
791                ``offset`` is negative.
792
793        """
794        if limit is not None and limit < 1:
795            msg = f"limit must be >= 1, got {limit}"
796            raise ValueError(msg)
797        if offset < 0:
798            msg = f"offset must be >= 0, got {offset}"
799            raise ValueError(msg)
800
801        page_size = 5
802        collected: list[_TripModel] = []
803        skipped = 0
804        api_offset = 0
805        while True:
806            # Once limit is known we can stop as soon as we've collected
807            # enough trips beyond the requested offset - no need to keep
808            # paginating through the entire remaining result set.
809            resp = await self._api.get_trips(
810                self.vin,
811                from_date,
812                to_date,
813                summary=False,
814                limit=page_size,
815                offset=api_offset,
816            )
817            if resp.payload is None:
818                break
819
820            for t in resp.payload.trips or []:
821                if skipped < offset:
822                    skipped += 1
823                    continue
824                collected.append(t)
825                if limit is not None and len(collected) >= limit:
826                    break
827
828            api_offset = resp.payload.metadata.pagination.next_offset
829            if api_offset is None or (limit is not None and len(collected) >= limit):
830                break
831
832        if full_route:
833            await self._load_routes(collected)
834        return [Trip(t, self._metric) for t in collected]

Return information on 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. Costs one extra API call per trip, as routes are only served by the trip detail endpoint.
  • limit (int | None, optional): Maximum total number of trips to return to the caller. None (the default) returns every trip in the requested date range, preserving the previous behaviour of paginating through all pages. If set, at most limit trips are returned, fetching only as many pages from the API as necessary.
  • offset (int, optional): Number of trips to skip from the start of the (most-recent-first) result set before applying limit. Defaults to 0.
Returns:

Optional[list[Trip]]: A list of trips (bounded by limit when given) or None if not supported.

Raises:
  • ValueError: limit is not None and is less than 1, or offset is negative.
async def get_last_trip(self, offset: int = 0) -> pytoyoda.models.trips.Trip | None:
836    async def get_last_trip(self, offset: int = 0) -> Trip | None:
837        """Return information on the Nth-most-recent trip.
838
839        Args:
840            offset (int, optional): 0-based position in the
841                most-recent-first trip history. ``0`` (the default) returns
842                the most recent trip, ``1`` returns the second most recent
843                trip, and so on.
844
845        Returns:
846            Optional[Trip]: A trip model, or None if not supported or if
847            there is no trip at the requested ``offset``.
848
849        Raises:
850            ValueError: ``offset`` is negative.
851
852        """
853        trips = await self.get_trips(
854            date.today() - timedelta(days=90),  # noqa : DTZ011
855            date.today(),  # noqa : DTZ011
856            full_route=False,
857            limit=1,
858            offset=offset,
859        )
860
861        if not trips:
862            return None
863        return trips[0]

Return information on the Nth-most-recent trip.

Arguments:
  • offset (int, optional): 0-based position in the most-recent-first trip history. 0 (the default) returns the most recent trip, 1 returns the second most recent trip, and so on.
Returns:

Optional[Trip]: A trip model, or None if not supported or if there is no trip at the requested offset.

Raises:
  • ValueError: offset is negative.
async def get_recent_trips( self, limit: int = 5, with_route: bool = False, from_date: datetime.date | None = None, to_date: datetime.date | None = None, offset: int = 0) -> list[pytoyoda.models.trips.Trip]:
865    async def get_recent_trips(
866        self,
867        limit: int = 5,
868        with_route: bool = False,  # noqa: FBT001, FBT002
869        from_date: date | None = None,
870        to_date: date | None = None,
871        offset: int = 0,
872    ) -> list[Trip]:
873        """Fetch a single page of trips, most-recent-first (one HTTP call).
874
875        Args:
876            limit: Page size, 1..50.
877            with_route: Include per-trip route coordinates (one extra API call
878                per trip).
879            from_date: Lower bound, inclusive. Defaults to today minus 90 days.
880            to_date: Upper bound, inclusive. Defaults to today.
881            offset: 0-based offset into the most-recent-first result set.
882
883        Returns:
884            List of Trip models, empty if no trips match.
885
886        Raises:
887            ValueError: ``limit`` outside 1..50, or ``offset`` negative.
888
889        """
890        if not 1 <= limit <= 50:  # noqa: PLR2004
891            msg = f"limit must be between 1 and 50, got {limit}"
892            raise ValueError(msg)
893        if offset < 0:
894            msg = f"offset must be >= 0, got {offset}"
895            raise ValueError(msg)
896        if from_date is None:
897            from_date = date.today() - timedelta(days=90)  # noqa: DTZ011
898        if to_date is None:
899            to_date = date.today()  # noqa: DTZ011
900
901        resp = await self._api.get_trips(
902            self.vin,
903            from_date,
904            to_date,
905            summary=False,
906            limit=limit,
907            offset=offset,
908        )
909
910        if resp.payload is None or not resp.payload.trips:
911            return []
912
913        if with_route:
914            await self._load_routes(resp.payload.trips)
915        return [Trip(t, self._metric) for t in resp.payload.trips]

Fetch a single page of trips, most-recent-first (one HTTP call).

Arguments:
  • limit: Page size, 1..50.
  • with_route: Include per-trip route coordinates (one extra API call per trip).
  • from_date: Lower bound, inclusive. Defaults to today minus 90 days.
  • to_date: Upper bound, inclusive. Defaults to today.
  • offset: 0-based offset into the most-recent-first result set.
Returns:

List of Trip models, empty if no trips match.

Raises:
  • ValueError: limit outside 1..50, or offset negative.
async def refresh_climate_status(self) -> pytoyoda.models.endpoints.common.StatusModel:
935    async def refresh_climate_status(self) -> StatusModel:
936        """Force update of climate status.
937
938        Returns:
939            StatusModel: A status response for the command.
940
941        """
942        return await self._api.refresh_climate_status(self.vin)

Force update of climate status.

Returns:

StatusModel: A status response for the command.

944    async def set_climate(
945        self, request: V2RemoteClimateControlRequestModel
946    ) -> RemoteClimateControlResponseModel:
947        """Start or stop remote climate control (POST /v2/remote/climate-control).
948
949        A ``start`` request carries the full desired settings (temperature +
950        heating/seat options + ``save_settings``); a ``stop`` is just
951        ``command="stop"``. Acknowledgement is ``response.payload.return_code ==
952        "000000"``; confirm the actual on/off state via the climate-status read.
953
954        Args:
955            request: The V2 climate-control request body.
956
957        Returns:
958            RemoteClimateControlResponseModel: The command acknowledgement.
959
960        """
961        return await self._api.send_climate_control_command(self.vin, request)

Start or stop remote climate control (POST /v2/remote/climate-control).

A start request carries the full desired settings (temperature + heating/seat options + save_settings); a stop is just command="stop". Acknowledgement is response.payload.return_code == "000000"; confirm the actual on/off state via the climate-status read.

Arguments:
  • request: The V2 climate-control request body.
Returns:

RemoteClimateControlResponseModel: The command acknowledgement.

async def post_command( self, command: pytoyoda.models.endpoints.command.CommandType, beeps: int = 0) -> pytoyoda.models.endpoints.common.StatusModel:
963    async def post_command(self, command: CommandType, beeps: int = 0) -> StatusModel:
964        """Send remote command to the vehicle.
965
966        Args:
967            command (CommandType): The remote command model
968            beeps (int): Amount of beeps for commands that support it
969
970        Returns:
971            StatusModel: A status response for the command.
972
973        """
974        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.

976    async def send_next_charging_command(
977        self, command: NextChargeSettings
978    ) -> ElectricCommandResponseModel:
979        """Send the next command to the vehicle.
980
981        Args:
982            command: NextChargeSettings command to send
983
984        Returns:
985            Model containing status of the command request
986
987        """
988        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:
 994    async def set_alias(
 995        self,
 996        value: bool,  # noqa : FBT001
 997    ) -> bool:
 998        """Set the alias for the vehicle.
 999
1000        Args:
1001            value: The alias value to set for the vehicle.
1002
1003        Returns:
1004            bool: Indicator if value is set
1005
1006        """
1007        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