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
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.
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
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.
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.
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.
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
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
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
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
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
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
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
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.
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).
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.
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.
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.
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.
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.
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
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
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 to0.
Returns:
list[Summary]: A list of summaries or empty list if not supported.
Raises:
- ValueError:
limitis notNoneand is less than 1, oroffsetis negative.
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.
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.
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.
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.
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 mostlimittrips 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 to0.
Returns:
Optional[list[Trip]]: A list of trips (bounded by
limitwhen given) or None if not supported.
Raises:
- ValueError:
limitis notNoneand is less than 1, oroffsetis negative.
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,1returns 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:
offsetis negative.
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:
limitoutside 1..50, oroffsetnegative.
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.
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
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