Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions custom_components/kia_uvo/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on this I wonder if we should have setting to turn this on and off? As well do we want to be polling this every time using API calls?

# 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:
Expand Down
100 changes: 99 additions & 1 deletion custom_components/kia_uvo/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this mean it will show up for people with cars that don't support it?

# 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

Expand Down Expand Up @@ -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")
3 changes: 3 additions & 0 deletions custom_components/kia_uvo/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@
"daily_driving_stats": {
"name": "Daily Driving Stats"
},
"day_trip_info": {
"name": "Day Trip Info"
},
"data": {
"name": "Data"
},
Expand Down
3 changes: 3 additions & 0 deletions custom_components/kia_uvo/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,9 @@
"daily_driving_stats": {
"name": "Daily Driving Stats"
},
"day_trip_info": {
"name": "Day Trip Info"
},
"data": {
"name": "Data"
},
Expand Down
Loading