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