diff --git a/custom_components/kia_uvo/coordinator.py b/custom_components/kia_uvo/coordinator.py index 58a92fa6..5c513c5d 100644 --- a/custom_components/kia_uvo/coordinator.py +++ b/custom_components/kia_uvo/coordinator.py @@ -178,6 +178,26 @@ async def _async_update_data(self): self.vehicle_manager.update_all_vehicles_with_cached_state ) + # Per-trip data. The library implements update_day_trip_info() but the + # upstream coordinator never calls it. Trip data must not fail the + # coordinator update — wrap in try/except so a NoDataFound, an + # unsupported region/firmware, or a transient backend error becomes a + # DEBUG log line rather than UpdateFailed. + today_str = dt_util.now().strftime("%Y%m%d") + for vehicle_id in self.vehicle_manager.vehicles: + try: + await self.hass.async_add_executor_job( + self.vehicle_manager.update_day_trip_info, + vehicle_id, + today_str, + ) + except Exception as exc: + _LOGGER.debug( + "Day trip info not available for vehicle %s: %s", + vehicle_id, + exc, + ) + return self.data async def async_update_all(self) -> None: diff --git a/custom_components/kia_uvo/sensor.py b/custom_components/kia_uvo/sensor.py index c60a5d8e..f3e862cb 100644 --- a/custom_components/kia_uvo/sensor.py +++ b/custom_components/kia_uvo/sensor.py @@ -4,7 +4,7 @@ import logging from typing import Final -from datetime import date +from datetime import date, datetime, timedelta from hyundai_kia_connect_api import Vehicle @@ -428,6 +428,14 @@ async def async_setup_entry( entities.append( VehicleEntity(coordinator, coordinator.vehicle_manager.vehicles[vehicle_id]) ) + # day_trip_info starts None and is populated by the coordinator's + # first update — register unconditionally so the entity exists even + # when no trip data has been fetched yet (TRIP-01). + entities.append( + DayTripInfoEntity( + coordinator, coordinator.vehicle_manager.vehicles[vehicle_id] + ) + ) async_add_entities(entities) return True @@ -588,3 +596,93 @@ def state_attributes(self): @property def unique_id(self): return f"{DOMAIN}-todays-daily-driving-stats-{self.vehicle.id}" + + +class DayTripInfoEntity(SensorEntity, HyundaiKiaConnectEntity): + """Per-trip sensor for today (TRIP-01). + + State is the number of trips today; attributes carry the full per-trip list + (start time, drive/idle time, distance, avg/max speed). The underlying + ``vehicle.day_trip_info`` is populated by the coordinator on each cached-state + poll cycle via ``VehicleManager.update_day_trip_info()``; before the first + successful fetch (or when the trip endpoint is unsupported / returns + NoDataFound for this region/firmware) the value is ``None`` — both + ``state`` and ``state_attributes`` handle that case so the entity remains + available with state ``0`` instead of going unavailable. + """ + + _attr_translation_key = "day_trip_info" + _attr_icon = "mdi:calendar" + + def __init__(self, coordinator, vehicle: Vehicle): + super().__init__(coordinator, vehicle) + + @property + def state(self): + if self.vehicle.day_trip_info is None: + return 0 + return len(self.vehicle.day_trip_info.trip_list) + + @property + def state_attributes(self): + if self.vehicle.day_trip_info is None: + return {} + info = self.vehicle.day_trip_info + date_iso = _iso_date(info.yyyymmdd) + summary = info.summary + return { + "date": date_iso, + "summary": { + "drive_time": summary.drive_time, + "idle_time": summary.idle_time, + "distance": summary.distance, + "avg_speed": summary.avg_speed, + "max_speed": summary.max_speed, + } + if summary is not None + else None, + "trip_list": [ + { + "start_time": _iso_datetime(date_iso, t.hhmmss), + "end_time": _iso_datetime_add( + date_iso, t.hhmmss, (t.drive_time or 0) + (t.idle_time or 0) + ), + "drive_time": t.drive_time, + "idle_time": t.idle_time, + "distance": t.distance, + "avg_speed": t.avg_speed, + "max_speed": t.max_speed, + } + for t in info.trip_list + ], + } + + @property + def unique_id(self): + return f"{DOMAIN}-day-trip-info-{self.vehicle.id}" + + +def _iso_date(yyyymmdd): + """Convert YYYYMMDD packed string to ISO YYYY-MM-DD, or None if unset.""" + if not yyyymmdd or len(yyyymmdd) != 8: + return None + return f"{yyyymmdd[0:4]}-{yyyymmdd[4:6]}-{yyyymmdd[6:8]}" + + +def _iso_datetime(date_iso, hhmmss): + """Combine an ISO date with a packed HHMMSS string into ISO 8601.""" + if not date_iso or not hhmmss or len(hhmmss) != 6: + return None + return f"{date_iso}T{hhmmss[0:2]}:{hhmmss[2:4]}:{hhmmss[4:6]}" + + +def _iso_datetime_add(date_iso, hhmmss, minutes): + """Return the naive ISO 8601 timestamp `minutes` after (date_iso, hhmmss).""" + start_iso = _iso_datetime(date_iso, hhmmss) + if start_iso is None: + return None + try: + start = datetime.fromisoformat(start_iso) + except (TypeError, ValueError): + return None + return (start + timedelta(minutes=int(minutes))).isoformat(timespec="seconds") diff --git a/custom_components/kia_uvo/strings.json b/custom_components/kia_uvo/strings.json index 68a37a31..8c4505fb 100644 --- a/custom_components/kia_uvo/strings.json +++ b/custom_components/kia_uvo/strings.json @@ -97,6 +97,9 @@ "daily_driving_stats": { "name": "Daily Driving Stats" }, + "day_trip_info": { + "name": "Day Trip Info" + }, "data": { "name": "Data" }, diff --git a/custom_components/kia_uvo/translations/en.json b/custom_components/kia_uvo/translations/en.json index da50d40f..1986376d 100644 --- a/custom_components/kia_uvo/translations/en.json +++ b/custom_components/kia_uvo/translations/en.json @@ -403,6 +403,9 @@ "daily_driving_stats": { "name": "Daily Driving Stats" }, + "day_trip_info": { + "name": "Day Trip Info" + }, "data": { "name": "Data" },