Skip to content

Commit d9c97e5

Browse files
committed
feat: add VehicleProfile, capability properties, and EU profile fetching
- Add VehicleProfile dataclass (~50 fields) mapping EU /profile endpoint response (basic, device, option, serviceOption, batteryType, detailInfo, dtcCategory sections) - Add 13 capability properties on Vehicle (steering_wheel_heater_supported, sunroof_supported, is_left_hand_drive, etc.) that read from Vehicle.profile — return None when profile is unavailable - Wire _fetch_vehicle_profiles into login flow via VehicleManager.initialize_vehicles() so profiles auto-populate on login - KiaUvoApiEU override fetches /profile for each vehicle with graceful degradation on failure - No-op base methods on ApiImpl and ApiImplType1 so non-EU regions and test DummyApi are unaffected - 20 unit tests + EU profile fixture
1 parent 480c513 commit d9c97e5

8 files changed

Lines changed: 690 additions & 1 deletion

File tree

hyundai_kia_connect_api/ApiImpl.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ class ApiImpl:
140140
def __init__(self) -> None:
141141
"""Initialize."""
142142

143+
def _fetch_vehicle_profiles(self, token: Token, vehicles: list[Vehicle]) -> None:
144+
"""No-op base. Override in region subclasses with profile endpoint."""
145+
143146
def login(
144147
self,
145148
username: str,

hyundai_kia_connect_api/ApiImplType1.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
POIInfo,
1818
)
1919
from .Token import Token
20-
from .Vehicle import Vehicle
20+
from .Vehicle import Vehicle, VehicleProfile
2121

2222
from .utils import get_child_value, parse_datetime, get_index_into_hex_temp
2323

@@ -197,6 +197,13 @@ def get_vehicles(self, token: Token) -> list[Vehicle]:
197197
result.append(vehicle)
198198
return result
199199

200+
def _fetch_vehicle_profiles(self, token: Token, vehicles: list[Vehicle]) -> None:
201+
"""No-op base. Override in region subclasses with profile endpoint."""
202+
203+
def _map_vehicle_profile(self, profile_data: dict) -> VehicleProfile:
204+
"""Map API profile response dict to VehicleProfile dataclass."""
205+
return VehicleProfile()
206+
200207
def _get_time_from_string(self, value, timesection) -> dt.datetime.time:
201208
if value is not None:
202209
lastTwo = int(value[-2:])

hyundai_kia_connect_api/KiaUvoApiEU.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .Token import Token
2323
from .Vehicle import (
2424
Vehicle,
25+
VehicleProfile,
2526
DailyDrivingStats,
2627
MonthTripInfo,
2728
DayTripInfo,
@@ -152,6 +153,119 @@ def __init__(self, region: int, brand: int, language: str) -> None:
152153
"https://accounts-eu.genesis.com/realms/eugenesisidm/ga-api/redirect2"
153154
)
154155

156+
@staticmethod
157+
def _str(v):
158+
"""Coerce int-or-string option values to strings for capability comparisons."""
159+
if v is None:
160+
return None
161+
return str(v)
162+
163+
def _map_vehicle_profile(self, profile_data: dict) -> VehicleProfile:
164+
basic = profile_data.get("basic", {})
165+
device = profile_data.get("device", {})
166+
option = profile_data.get("option", {})
167+
service_option = profile_data.get("serviceOption", {})
168+
battery_type = profile_data.get("batteryType", {})
169+
detail_info = profile_data.get("detailInfo", {})
170+
dtc_category = profile_data.get("dtcCategory", [])
171+
172+
seat_heater_vent = option.get("seatHeaterVent", {})
173+
174+
# The EU API returns some option fields as integers and others as strings.
175+
# Capability properties (steering_wheel_heater_supported, etc.) compare
176+
# against string values ("1", "0"), so we coerce integer fields to strings.
177+
return VehicleProfile(
178+
# basic
179+
brand=basic.get("brand"),
180+
country=basic.get("country"),
181+
ota_update_supported=basic.get("ecuOtaUpdateSupport") == 1,
182+
remote_ota_update_supported=basic.get("ecuRemoteOTAUpdateSupport") == 1,
183+
# device
184+
sim_status=device.get("simStatus"),
185+
sim_start_date=device.get("simStartDate"),
186+
sim_end_date=device.get("simEndDate"),
187+
head_unit_type=device.get("headUnitType"),
188+
head_unit_model_name=device.get("headUnitModelName"),
189+
head_unit_version=device.get("currentHeadUnitVersion"),
190+
platform=device.get("platform"),
191+
navi_applied=device.get("naviApplied") == 1,
192+
web_manual_url=device.get("webManualUrl"),
193+
# option — string fields that may arrive as ints
194+
air_control_type=option.get("airControlType"),
195+
driver_seat_location=option.get("drvSeatLoc"),
196+
remote_control=option.get("remoteControl"),
197+
heating1=option.get("heating1"),
198+
heating_front_window=option.get("heatingFrontWindow"),
199+
steering_wheel_heat_option=option.get("strgWhlHeatOption"),
200+
heating_steering_wheel=option.get("heatingSteeringWheel"),
201+
heating_side_mirror=option.get("heatingSideMirror"),
202+
heating_rear_window=option.get("heatingRearWindow"),
203+
light_only_available=self._str(option.get("lightOnlyAvailable")),
204+
horn_light_available=self._str(option.get("hornLightAvailable")),
205+
hvac_temp_type=option.get("hvacTempType"),
206+
remote_control_waiting_time=option.get("remoteControlWaitingTime"),
207+
window_safety_option2=option.get("windowSafetyOption2"),
208+
sunroof_option=self._str(option.get("sunRoofOption")),
209+
digital_key2=self._str(option.get("digitalKey2")),
210+
remote_heat_control=option.get("remoteHeatControl"),
211+
air_purifier_option=self._str(option.get("airPurifierOption")),
212+
dvrs_option=option.get("dvrsOption"),
213+
ignition_control_option=self._str(option.get("ignCtrlOption")),
214+
seat_heater_vent_front_left=seat_heater_vent.get("flSeatHeat"),
215+
seat_heater_vent_front_right=seat_heater_vent.get("frSeatHeat"),
216+
seat_heater_vent_rear_left=seat_heater_vent.get("rlSeatHeat"),
217+
seat_heater_vent_rear_right=seat_heater_vent.get("rrSeatHeat"),
218+
ev_alarm_option_info=self._str(option.get("evAlarmOptionInfo")),
219+
remote_air_ctrl_control_option=option.get("remoteAirCtrlControlOption"),
220+
# serviceOption
221+
battery_warning_service=service_option.get("batteryWarningService") == 1,
222+
schedule_link_service=service_option.get("scheduleLinkService") == 1,
223+
center_user_profile_option=service_option.get("centerUserProfileOption"),
224+
final_destination_noti=service_option.get("finalDestinationNoti") == 1,
225+
valet_service_option=service_option.get("valetServiceOption") == 1,
226+
notification_support=service_option.get("notificationSupport") == 1,
227+
remote_valet_act_option=service_option.get("remoteValetActOption") == 1,
228+
alert_service_option=service_option.get("alertServiceOption") == 1,
229+
media_streaming_service=service_option.get("mediaStreamingService"),
230+
media_streaming_selection_option=service_option.get(
231+
"mediaStreamingSelectionOption"
232+
)
233+
== 1,
234+
idle_alert_setting_service=service_option.get("idleAlertSettingService")
235+
== 1,
236+
engine_idle_time_notification=service_option.get(
237+
"engineIdleTimeNotification"
238+
)
239+
== 1,
240+
send2car_option_info=service_option.get("send2CarOptionInfo"),
241+
speed_event_support=service_option.get("speedEventSupport") == 1,
242+
# batteryType
243+
main_battery_type=battery_type.get("mainbatteryType"),
244+
aux_battery_type=battery_type.get("auxbatteryType"),
245+
# detailInfo
246+
sale_model_code=detail_info.get("saleCarmdlCd"),
247+
body_type=detail_info.get("bodyType"),
248+
interior_color=detail_info.get("inColor"),
249+
exterior_color=detail_info.get("outColor"),
250+
# dtcCategory
251+
dtc_categories=dtc_category if dtc_category else None,
252+
)
253+
254+
def _fetch_vehicle_profiles(self, token: Token, vehicles: list[Vehicle]) -> None:
255+
for vehicle in vehicles:
256+
try:
257+
url = self.SPA_API_URL + f"vehicles/{vehicle.id}/profile"
258+
headers = self._get_authenticated_headers(token)
259+
response = requests.get(url, headers=headers, timeout=30)
260+
_check_response_for_errors(response.json())
261+
profile_data = response.json().get("resMsg", {}).get("vinInfo", [])
262+
if profile_data:
263+
vehicle.profile = self._map_vehicle_profile(profile_data[0])
264+
except Exception:
265+
_LOGGER.debug(
266+
f"{DOMAIN} - Vehicle profile fetch failed for {vehicle.id}"
267+
)
268+
155269
def login(
156270
self,
157271
username: str,

hyundai_kia_connect_api/Vehicle.py

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

6666

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

363440
# Debug fields
364441
data: dict = None
442+
profile: VehicleProfile | None = None
365443

366444
@property
367445
def geocode(self):
@@ -653,3 +731,83 @@ def fuel_driving_range(self, value):
653731
self._fuel_driving_range_value = value[0]
654732
self._fuel_driving_range_unit = value[1]
655733
self._fuel_driving_range = value[0]
734+
735+
# Capability properties from VehicleProfile (flat, for HA getattr pattern)
736+
737+
@property
738+
def steering_wheel_heater_supported(self) -> bool | None:
739+
if self.profile is None:
740+
return None
741+
return self.profile.heating_steering_wheel == "1"
742+
743+
@property
744+
def side_mirror_heater_supported(self) -> bool | None:
745+
if self.profile is None:
746+
return None
747+
return self.profile.heating_side_mirror == "1"
748+
749+
@property
750+
def rear_window_heater_supported(self) -> bool | None:
751+
if self.profile is None:
752+
return None
753+
return self.profile.heating_rear_window == "1"
754+
755+
@property
756+
def sunroof_supported(self) -> bool | None:
757+
if self.profile is None:
758+
return None
759+
return self.profile.sunroof_option == "1"
760+
761+
@property
762+
def digital_key_supported(self) -> bool | None:
763+
if self.profile is None:
764+
return None
765+
return self.profile.digital_key2 != "0"
766+
767+
@property
768+
def air_purifier_supported(self) -> bool | None:
769+
if self.profile is None:
770+
return None
771+
return self.profile.air_purifier_option == "1"
772+
773+
@property
774+
def remote_heat_control_supported(self) -> bool | None:
775+
if self.profile is None:
776+
return None
777+
return self.profile.remote_heat_control != "0"
778+
779+
@property
780+
def ignition_control_supported(self) -> bool | None:
781+
if self.profile is None:
782+
return None
783+
return self.profile.ignition_control_option == "1"
784+
785+
@property
786+
def horn_light_supported(self) -> bool | None:
787+
if self.profile is None:
788+
return None
789+
return self.profile.horn_light_available == "1"
790+
791+
@property
792+
def light_only_supported(self) -> bool | None:
793+
if self.profile is None:
794+
return None
795+
return self.profile.light_only_available == "1"
796+
797+
@property
798+
def ev_alarm_supported(self) -> bool | None:
799+
if self.profile is None:
800+
return None
801+
return self.profile.ev_alarm_option_info != "0"
802+
803+
@property
804+
def front_window_heating_supported(self) -> bool | None:
805+
if self.profile is None:
806+
return None
807+
return self.profile.heating_front_window == "1"
808+
809+
@property
810+
def is_left_hand_drive(self) -> bool | None:
811+
if self.profile is None:
812+
return None
813+
return self.profile.driver_seat_location == "L"

hyundai_kia_connect_api/VehicleManager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def initialize_vehicles(self):
127127
"Vehicles already initialized, this will re-initialize and cause data loss mapping errors"
128128
)
129129
vehicles = self.api.get_vehicles(self.token)
130+
self.api._fetch_vehicle_profiles(self.token, vehicles)
130131
for vehicle in vehicles:
131132
vehicle.supports_window_control = self.api.supports_window_control
132133
self.vehicles[vehicle.id] = vehicle

0 commit comments

Comments
 (0)