Skip to content

Commit 9af9c7a

Browse files
committed
feat: add VehicleProfile and capability properties for EU API
- VehicleProfile dataclass (~50 fields) mapping EU /api/v1/spa/vehicles/{id}/profile - 13 capability properties on Vehicle reading from Vehicle.profile (HA getattr pattern, None when no profile) - str_or_none utility for int-or-string option coercion - supports_vehicle_profile flag on ApiImpl (False by default), True on KiaUvoApiEU - _fetch_vehicle_profiles + _map_vehicle_profile in ApiImplType1, piggybacked on get_vehicles (login only, not per poll) - Graceful degradation: profile fetch failure leaves vehicle.profile = None - 21 unit tests + EU profile fixture Non-supporting regions (CA, USA, BR) make zero extra calls. AU/IN/CN stay off until their /profile schema is verified.
1 parent ac37537 commit 9af9c7a

9 files changed

Lines changed: 900 additions & 1 deletion

File tree

hyundai_kia_connect_api/ApiImpl.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class ApiImpl:
164164
previous_longitude: float = None
165165
supports_window_control: bool = False
166166
supports_valet_mode: bool = False
167+
supports_vehicle_profile: bool = False
167168

168169
def __init__(self) -> None:
169170
"""Initialize."""

hyundai_kia_connect_api/ApiImplType1.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
POIInfo,
1919
)
2020
from .Token import Token
21-
from .Vehicle import Vehicle
21+
from .Vehicle import Vehicle, VehicleProfile
2222

2323
from .utils import (
2424
bool_or_none,
@@ -27,6 +27,7 @@
2727
normalize_battery_soc,
2828
parse_datetime,
2929
pressure_or_none,
30+
str_or_none,
3031
window_is_open,
3132
)
3233

@@ -232,8 +233,117 @@ def get_vehicles(self, token: Token) -> list[Vehicle]:
232233
ccu_ccs2_protocol_support=entry["ccuCCS2ProtocolSupport"],
233234
)
234235
result.append(vehicle)
236+
if self.supports_vehicle_profile:
237+
self._fetch_vehicle_profiles(token, result)
235238
return result
236239

240+
def _fetch_vehicle_profiles(self, token: Token, vehicles: list[Vehicle]) -> None:
241+
for vehicle in vehicles:
242+
try:
243+
url = self.SPA_API_URL + f"vehicles/{vehicle.id}/profile"
244+
headers = self._get_authenticated_headers(token)
245+
response = self.session.get(url, headers=headers)
246+
_check_response_for_errors(response.json())
247+
profile_data = response.json().get("resMsg", {}).get("vinInfo", [])
248+
if profile_data:
249+
vehicle.profile = self._map_vehicle_profile(profile_data[0])
250+
except Exception:
251+
_LOGGER.debug(
252+
f"{DOMAIN} - Vehicle profile fetch failed for {vehicle.id}"
253+
)
254+
255+
def _map_vehicle_profile(self, profile_data: dict) -> VehicleProfile:
256+
"""Map API profile response dict to VehicleProfile dataclass."""
257+
basic = profile_data.get("basic", {})
258+
device = profile_data.get("device", {})
259+
option = profile_data.get("option", {})
260+
service_option = profile_data.get("serviceOption", {})
261+
battery_type = profile_data.get("batteryType", {})
262+
detail_info = profile_data.get("detailInfo", {})
263+
dtc_category = profile_data.get("dtcCategory", [])
264+
265+
seat_heater_vent = option.get("seatHeaterVent", {})
266+
267+
# The API returns some option fields as integers and others as strings.
268+
# Capability properties compare against string values ("1", "0"), so
269+
# coerce integer fields to strings via str_or_none (None-safe).
270+
return VehicleProfile(
271+
# basic
272+
brand=basic.get("brand"),
273+
country=basic.get("country"),
274+
ota_update_supported=basic.get("ecuOtaUpdateSupport") == 1,
275+
remote_ota_update_supported=basic.get("ecuRemoteOTAUpdateSupport") == 1,
276+
# device
277+
sim_status=device.get("simStatus"),
278+
sim_start_date=device.get("simStartDate"),
279+
sim_end_date=device.get("simEndDate"),
280+
head_unit_type=device.get("headUnitType"),
281+
head_unit_model_name=device.get("headUnitModelName"),
282+
head_unit_version=device.get("currentHeadUnitVersion"),
283+
platform=device.get("platform"),
284+
navi_applied=device.get("naviApplied") == 1,
285+
web_manual_url=device.get("webManualUrl"),
286+
# option — string fields that may arrive as ints
287+
air_control_type=option.get("airControlType"),
288+
driver_seat_location=option.get("drvSeatLoc"),
289+
remote_control=option.get("remoteControl"),
290+
heating1=option.get("heating1"),
291+
heating_front_window=option.get("heatingFrontWindow"),
292+
steering_wheel_heat_option=option.get("strgWhlHeatOption"),
293+
heating_steering_wheel=option.get("heatingSteeringWheel"),
294+
heating_side_mirror=option.get("heatingSideMirror"),
295+
heating_rear_window=option.get("heatingRearWindow"),
296+
light_only_available=str_or_none(option.get("lightOnlyAvailable")),
297+
horn_light_available=str_or_none(option.get("hornLightAvailable")),
298+
hvac_temp_type=option.get("hvacTempType"),
299+
remote_control_waiting_time=option.get("remoteControlWaitingTime"),
300+
window_safety_option2=option.get("windowSafetyOption2"),
301+
sunroof_option=str_or_none(option.get("sunRoofOption")),
302+
digital_key2=str_or_none(option.get("digitalKey2")),
303+
remote_heat_control=option.get("remoteHeatControl"),
304+
air_purifier_option=str_or_none(option.get("airPurifierOption")),
305+
dvrs_option=option.get("dvrsOption"),
306+
ignition_control_option=str_or_none(option.get("ignCtrlOption")),
307+
seat_heater_vent_front_left=seat_heater_vent.get("flSeatHeat"),
308+
seat_heater_vent_front_right=seat_heater_vent.get("frSeatHeat"),
309+
seat_heater_vent_rear_left=seat_heater_vent.get("rlSeatHeat"),
310+
seat_heater_vent_rear_right=seat_heater_vent.get("rrSeatHeat"),
311+
ev_alarm_option_info=str_or_none(option.get("evAlarmOptionInfo")),
312+
remote_air_ctrl_control_option=option.get("remoteAirCtrlControlOption"),
313+
# serviceOption
314+
battery_warning_service=service_option.get("batteryWarningService") == 1,
315+
schedule_link_service=service_option.get("scheduleLinkService") == 1,
316+
center_user_profile_option=service_option.get("centerUserProfileOption"),
317+
final_destination_noti=service_option.get("finalDestinationNoti") == 1,
318+
valet_service_option=service_option.get("valetServiceOption") == 1,
319+
notification_support=service_option.get("notificationSupport") == 1,
320+
remote_valet_act_option=service_option.get("remoteValetActOption") == 1,
321+
alert_service_option=service_option.get("alertServiceOption") == 1,
322+
media_streaming_service=service_option.get("mediaStreamingService"),
323+
media_streaming_selection_option=service_option.get(
324+
"mediaStreamingSelectionOption"
325+
)
326+
== 1,
327+
idle_alert_setting_service=service_option.get("idleAlertSettingService")
328+
== 1,
329+
engine_idle_time_notification=service_option.get(
330+
"engineIdleTimeNotification"
331+
)
332+
== 1,
333+
send2car_option_info=service_option.get("send2CarOptionInfo"),
334+
speed_event_support=service_option.get("speedEventSupport") == 1,
335+
# batteryType
336+
main_battery_type=battery_type.get("mainbatteryType"),
337+
aux_battery_type=battery_type.get("auxbatteryType"),
338+
# detailInfo
339+
sale_model_code=detail_info.get("saleCarmdlCd"),
340+
body_type=detail_info.get("bodyType"),
341+
interior_color=detail_info.get("inColor"),
342+
exterior_color=detail_info.get("outColor"),
343+
# dtcCategory
344+
dtc_categories=dtc_category if dtc_category else None,
345+
)
346+
237347
def _get_time_from_string(self, value, timesection) -> dt.time | None:
238348
if value is None:
239349
return None

hyundai_kia_connect_api/KiaUvoApiEU.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
class KiaUvoApiEU(ApiImplType1):
8181
data_timezone = ZoneInfo("Europe/Berlin")
8282
temperature_range = [x * 0.5 for x in range(28, 60)]
83+
supports_vehicle_profile: bool = True
8384

8485
def __init__(self, region: int, brand: int, language: str) -> None:
8586
language = language.lower()

hyundai_kia_connect_api/Vehicle.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,83 @@ class DailyDrivingStats:
6363
distance_unit: str = DISTANCE_UNITS[1] # set to kms by default
6464

6565

66+
@dataclass
67+
class VehicleProfile:
68+
# basic
69+
brand: str | None = None
70+
country: str | None = None
71+
ota_update_supported: bool | None = None
72+
remote_ota_update_supported: bool | None = None
73+
74+
# device
75+
sim_status: str | None = None
76+
sim_start_date: str | None = None
77+
sim_end_date: str | None = None
78+
head_unit_type: str | None = None
79+
head_unit_model_name: str | None = None
80+
head_unit_version: str | None = None
81+
platform: str | None = None
82+
navi_applied: bool | None = None
83+
web_manual_url: str | None = None
84+
85+
# option (raw API values)
86+
air_control_type: str | None = None
87+
driver_seat_location: str | None = None
88+
remote_control: str | None = None
89+
heating1: str | None = None
90+
heating_front_window: str | None = None
91+
steering_wheel_heat_option: str | None = None
92+
heating_steering_wheel: str | None = None
93+
heating_side_mirror: str | None = None
94+
heating_rear_window: str | None = None
95+
light_only_available: str | None = None
96+
horn_light_available: str | None = None
97+
hvac_temp_type: str | None = None
98+
remote_control_waiting_time: int | None = None
99+
window_safety_option2: int | None = None
100+
sunroof_option: str | None = None
101+
digital_key2: str | None = None
102+
remote_heat_control: str | None = None
103+
air_purifier_option: str | None = None
104+
dvrs_option: str | None = None
105+
ignition_control_option: str | None = None
106+
seat_heater_vent_front_left: int | None = None
107+
seat_heater_vent_front_right: int | None = None
108+
seat_heater_vent_rear_left: int | None = None
109+
seat_heater_vent_rear_right: int | None = None
110+
ev_alarm_option_info: str | None = None
111+
remote_air_ctrl_control_option: str | None = None
112+
113+
# serviceOption
114+
battery_warning_service: bool | None = None
115+
schedule_link_service: bool | None = None
116+
center_user_profile_option: int | None = None
117+
final_destination_noti: bool | None = None
118+
valet_service_option: bool | None = None
119+
notification_support: bool | None = None
120+
remote_valet_act_option: bool | None = None
121+
alert_service_option: bool | None = None
122+
media_streaming_service: list[int] | None = None
123+
media_streaming_selection_option: bool | None = None
124+
idle_alert_setting_service: bool | None = None
125+
engine_idle_time_notification: bool | None = None
126+
send2car_option_info: int | None = None
127+
speed_event_support: bool | None = None
128+
129+
# batteryType
130+
main_battery_type: int | None = None
131+
aux_battery_type: int | None = None
132+
133+
# detailInfo
134+
sale_model_code: str | None = None
135+
body_type: str | None = None
136+
interior_color: str | None = None
137+
exterior_color: str | None = None
138+
139+
# dtcCategory
140+
dtc_categories: list | None = None
141+
142+
66143
@dataclass
67144
class Vehicle:
68145
id: str = None
@@ -390,6 +467,7 @@ def day_trip_info(self, value):
390467

391468
# Debug fields
392469
data: dict = None
470+
profile: VehicleProfile | None = None
393471

394472
@property
395473
def geocode(self):
@@ -730,3 +808,83 @@ def fuel_driving_range(self, value):
730808
self._fuel_driving_range = value[0]
731809
if value[1] is not None:
732810
self._fuel_driving_range_unit = value[1]
811+
812+
# Capability properties from VehicleProfile (flat, for HA getattr pattern)
813+
814+
@property
815+
def steering_wheel_heater_supported(self) -> bool | None:
816+
if self.profile is None:
817+
return None
818+
return self.profile.heating_steering_wheel == "1"
819+
820+
@property
821+
def side_mirror_heater_supported(self) -> bool | None:
822+
if self.profile is None:
823+
return None
824+
return self.profile.heating_side_mirror == "1"
825+
826+
@property
827+
def rear_window_heater_supported(self) -> bool | None:
828+
if self.profile is None:
829+
return None
830+
return self.profile.heating_rear_window == "1"
831+
832+
@property
833+
def sunroof_supported(self) -> bool | None:
834+
if self.profile is None:
835+
return None
836+
return self.profile.sunroof_option == "1"
837+
838+
@property
839+
def digital_key_supported(self) -> bool | None:
840+
if self.profile is None:
841+
return None
842+
return self.profile.digital_key2 != "0"
843+
844+
@property
845+
def air_purifier_supported(self) -> bool | None:
846+
if self.profile is None:
847+
return None
848+
return self.profile.air_purifier_option == "1"
849+
850+
@property
851+
def remote_heat_control_supported(self) -> bool | None:
852+
if self.profile is None:
853+
return None
854+
return self.profile.remote_heat_control != "0"
855+
856+
@property
857+
def ignition_control_supported(self) -> bool | None:
858+
if self.profile is None:
859+
return None
860+
return self.profile.ignition_control_option == "1"
861+
862+
@property
863+
def horn_light_supported(self) -> bool | None:
864+
if self.profile is None:
865+
return None
866+
return self.profile.horn_light_available == "1"
867+
868+
@property
869+
def light_only_supported(self) -> bool | None:
870+
if self.profile is None:
871+
return None
872+
return self.profile.light_only_available == "1"
873+
874+
@property
875+
def ev_alarm_supported(self) -> bool | None:
876+
if self.profile is None:
877+
return None
878+
return self.profile.ev_alarm_option_info != "0"
879+
880+
@property
881+
def front_window_heating_supported(self) -> bool | None:
882+
if self.profile is None:
883+
return None
884+
return self.profile.heating_front_window == "1"
885+
886+
@property
887+
def is_left_hand_drive(self) -> bool | None:
888+
if self.profile is None:
889+
return None
890+
return self.profile.driver_seat_location == "L"

hyundai_kia_connect_api/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ def window_is_open(
6161
return bool(open_value) or bool(open_level)
6262

6363

64+
def str_or_none(v):
65+
"""Coerce an int-or-string option value to a string for capability comparisons.
66+
67+
Returns None when the value is None (capability stays None = unknown/unsupported).
68+
"""
69+
if v is None:
70+
return None
71+
return str(v)
72+
73+
6474
def get_float(value):
6575
if value is None:
6676
return None

0 commit comments

Comments
 (0)