From cb19f51fe6ee3e06318be743431e557d13e4600e Mon Sep 17 00:00:00 2001 From: Chetan Date: Thu, 23 Jul 2026 14:00:20 +0100 Subject: [PATCH 1/4] feat: Add EU tripinfo speed/duration fetching with local caching --- .../HyundaiEuropeAPIClient+Parsing.swift | 23 ++++- .../HyundaiEuropeAPIClient.swift | 83 ++++++++++++++++++- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift index 4449de7..5a0788a 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift @@ -424,10 +424,27 @@ extension HyundaiEuropeAPIClient { drivetrainEnergy: motorPwrCsp, batteryCareEnergy: batteryMgPwrCsp, startDate: startDate, - durationSeconds: 0, // Not provided - avgSpeed: 0, // Not provided - maxSpeed: 0 // Not provided + durationSeconds: 0, // Populated later from /tripinfo + avgSpeed: 0, // Populated later from /tripinfo + maxSpeed: 0 // Populated later from /tripinfo ) } } + + package func parseIndividualTripsResponse(_ data: Data) throws -> (driveTimeMinutes: Int, avgSpeed: Double, maxSpeed: Double) { + guard + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let resMsg = json["resMsg"] as? [String: Any], + let dayTripList = resMsg["dayTripList"] as? [[String: Any]], + let firstDay = dayTripList.first + else { + throw APIError(message: "Failed to parse EU individual trips", apiName: apiName) + } + + let totalDriveTime = firstDay["tripDrvTime"] as? Int ?? 0 + let avgSpeed = getDoubleFromJson(from: firstDay, key: "tripAvgSpeed") + let maxSpeed = getDoubleFromJson(from: firstDay, key: "tripMaxSpeed") + + return (driveTimeMinutes: totalDriveTime, avgSpeed: avgSpeed, maxSpeed: maxSpeed) + } } diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift index 50d4315..87cc894 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift @@ -326,6 +326,87 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol { vin: vehicle.vin ) - return try parseEVTripDetailsResponse(data, vehicle: vehicle) + var parsedTrips = try parseEVTripDetailsResponse(data, vehicle: vehicle) + + // Fetch tripinfo summaries to populate duration and speed metrics for each trip + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyyMMdd" + let todayString = dateFormatter.string(from: Date()) + + for i in 0.. (driveTimeMinutes: Int, avgSpeed: Double, maxSpeed: Double)? { + let cacheKey = "BBTripCache_\(vehicle.vin)_\(dateString)" + + // 1. Check cache first. Skip cache if it's today, OR if the total energy from /drvhistory has changed! + if !isToday, let cached = UserDefaults.standard.dictionary(forKey: cacheKey), + let cachedEnergy = cached["totalEnergyUsed"] as? Int, cachedEnergy == totalEnergyUsed { + return ( + driveTimeMinutes: cached["driveTimeMinutes"] as? Int ?? 0, + avgSpeed: cached["avgSpeed"] as? Double ?? 0, + maxSpeed: cached["maxSpeed"] as? Double ?? 0 + ) + } + + // 2. Fetch from API + do { + if let result = try await fetchIndividualTrips(for: vehicle, authToken: authToken, dateString: dateString) { + // 3. Save to cache with the current energy total + let cacheData: [String: Any] = [ + "driveTimeMinutes": result.driveTimeMinutes, + "avgSpeed": result.avgSpeed, + "maxSpeed": result.maxSpeed, + "totalEnergyUsed": totalEnergyUsed + ] + UserDefaults.standard.set(cacheData, forKey: cacheKey) + return result + } + } catch { + BBLogger.error(.api, "Failed to fetch individual trips for \(dateString): \(error)") + } + return nil + } + + private func fetchIndividualTrips(for vehicle: Vehicle, authToken: AuthToken, dateString: String) async throws -> (driveTimeMinutes: Int, avgSpeed: Double, maxSpeed: Double)? { + let ccs2 = vehicle.marketOptions?.ccs2Supported ?? false + let url = "\(baseURL)/api/v1/spa/vehicles/\(vehicle.regId)/tripinfo" + + let (data, _, _) = try await performJSONRequest( + url: url, + method: .POST, + headers: authorizedHeaders(authToken: authToken, ccs2: ccs2), + body: [ + "tripPeriodType": 1, + "setTripDay": dateString + ], + requestType: .fetchEVTripDetails, + vin: vehicle.vin + ) + + return try parseIndividualTripsResponse(data) } } From 50606b2c735f1ae4ea1bbe15bd283f7acd5ec4b3 Mon Sep 17 00:00:00 2001 From: Chetan Date: Fri, 24 Jul 2026 17:22:25 +0100 Subject: [PATCH 2/4] Refactor: Make trip history stateless and decouple trip info fetching. Added a new `fetchEVTripInfo` method to `APIClientProtocol` so clients can fetch the extra trip data separately, only when required. --- Sources/BetterBlueKit/API/APIClient.swift | 7 ++ .../HyundaiEuropeAPIClient+Parsing.swift | 29 +++++--- .../HyundaiEuropeAPIClient.swift | 66 +------------------ .../BetterBlueKit/Models/EVTripDetails.swift | 23 +++++++ 4 files changed, 53 insertions(+), 72 deletions(-) diff --git a/Sources/BetterBlueKit/API/APIClient.swift b/Sources/BetterBlueKit/API/APIClient.swift index 52c187e..3fd8c64 100644 --- a/Sources/BetterBlueKit/API/APIClient.swift +++ b/Sources/BetterBlueKit/API/APIClient.swift @@ -135,6 +135,9 @@ public protocol APIClientProtocol { /// Optional: Fetch EV trip details for a vehicle (not all brands/APIs support this) func fetchEVTripDetails(for vehicle: Vehicle, authToken: AuthToken) async throws -> [EVTripDetail]? + /// Optional: Fetch specific EV trip info summary for a given date (not all brands/APIs support this) + func fetchEVTripInfo(for vehicle: Vehicle, authToken: AuthToken, dateString: String) async throws -> [EVTripInfo]? + /// Returns true if this API client implements `fetchEVTripDetails` func supportsEVTripDetails() -> Bool @@ -170,6 +173,10 @@ extension APIClientProtocol { nil } + public func fetchEVTripInfo(for vehicle: Vehicle, authToken: AuthToken, dateString: String) async throws -> [EVTripInfo]? { + nil + } + public func supportsEVTripDetails() -> Bool { false } diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift index 5a0788a..1dec411 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift @@ -431,20 +431,33 @@ extension HyundaiEuropeAPIClient { } } - package func parseIndividualTripsResponse(_ data: Data) throws -> (driveTimeMinutes: Int, avgSpeed: Double, maxSpeed: Double) { + package func parseIndividualTripsResponse(_ data: Data) throws -> [EVTripInfo] { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let resMsg = json["resMsg"] as? [String: Any], - let dayTripList = resMsg["dayTripList"] as? [[String: Any]], - let firstDay = dayTripList.first + let dayTripList = resMsg["dayTripList"] as? [[String: Any]] else { throw APIError(message: "Failed to parse EU individual trips", apiName: apiName) } - let totalDriveTime = firstDay["tripDrvTime"] as? Int ?? 0 - let avgSpeed = getDoubleFromJson(from: firstDay, key: "tripAvgSpeed") - let maxSpeed = getDoubleFromJson(from: firstDay, key: "tripMaxSpeed") - - return (driveTimeMinutes: totalDriveTime, avgSpeed: avgSpeed, maxSpeed: maxSpeed) + return dayTripList.map { tripData in + let date = tripData["tripDate"] as? String ?? tripData["date"] as? String ?? "" + let hhmmss = tripData["tripTime"] as? String ?? tripData["hhmmss"] as? String ?? "" + let driveTime = tripData["tripDrvTime"] as? Int ?? tripData["drive_time"] as? Int ?? 0 + let idleTime = tripData["tripIdleTime"] as? Int ?? tripData["idle_time"] as? Int ?? 0 + let distance = getDoubleFromJson(from: tripData, key: "tripDistance") + let avgSpeed = getDoubleFromJson(from: tripData, key: "tripAvgSpeed") + let maxSpeed = getDoubleFromJson(from: tripData, key: "tripMaxSpeed") + + return EVTripInfo( + date: date, + hhmmss: hhmmss, + driveTimeMinutes: driveTime, + idleTimeMinutes: idleTime, + distance: distance, + avgSpeed: avgSpeed, + maxSpeed: maxSpeed + ) + } } } diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift index 87cc894..4c06325 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient.swift @@ -326,72 +326,10 @@ public final class HyundaiEuropeAPIClient: APIClientBase, APIClientProtocol { vin: vehicle.vin ) - var parsedTrips = try parseEVTripDetailsResponse(data, vehicle: vehicle) - - // Fetch tripinfo summaries to populate duration and speed metrics for each trip - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.dateFormat = "yyyyMMdd" - let todayString = dateFormatter.string(from: Date()) - - for i in 0.. (driveTimeMinutes: Int, avgSpeed: Double, maxSpeed: Double)? { - let cacheKey = "BBTripCache_\(vehicle.vin)_\(dateString)" - - // 1. Check cache first. Skip cache if it's today, OR if the total energy from /drvhistory has changed! - if !isToday, let cached = UserDefaults.standard.dictionary(forKey: cacheKey), - let cachedEnergy = cached["totalEnergyUsed"] as? Int, cachedEnergy == totalEnergyUsed { - return ( - driveTimeMinutes: cached["driveTimeMinutes"] as? Int ?? 0, - avgSpeed: cached["avgSpeed"] as? Double ?? 0, - maxSpeed: cached["maxSpeed"] as? Double ?? 0 - ) - } - - // 2. Fetch from API - do { - if let result = try await fetchIndividualTrips(for: vehicle, authToken: authToken, dateString: dateString) { - // 3. Save to cache with the current energy total - let cacheData: [String: Any] = [ - "driveTimeMinutes": result.driveTimeMinutes, - "avgSpeed": result.avgSpeed, - "maxSpeed": result.maxSpeed, - "totalEnergyUsed": totalEnergyUsed - ] - UserDefaults.standard.set(cacheData, forKey: cacheKey) - return result - } - } catch { - BBLogger.error(.api, "Failed to fetch individual trips for \(dateString): \(error)") - } - return nil + return try parseEVTripDetailsResponse(data, vehicle: vehicle) } - private func fetchIndividualTrips(for vehicle: Vehicle, authToken: AuthToken, dateString: String) async throws -> (driveTimeMinutes: Int, avgSpeed: Double, maxSpeed: Double)? { + public func fetchEVTripInfo(for vehicle: Vehicle, authToken: AuthToken, dateString: String) async throws -> [EVTripInfo]? { let ccs2 = vehicle.marketOptions?.ccs2Supported ?? false let url = "\(baseURL)/api/v1/spa/vehicles/\(vehicle.regId)/tripinfo" diff --git a/Sources/BetterBlueKit/Models/EVTripDetails.swift b/Sources/BetterBlueKit/Models/EVTripDetails.swift index e35dc23..effd192 100644 --- a/Sources/BetterBlueKit/Models/EVTripDetails.swift +++ b/Sources/BetterBlueKit/Models/EVTripDetails.swift @@ -103,3 +103,26 @@ public struct EVTripDetailsResponse: Codable, Sendable { self.trips = trips } } + +// MARK: - EV Trip Info + +/// Represents summary of a specific trip from the tripinfo endpoint +public struct EVTripInfo: Codable, Hashable, Sendable { + public let date: String + public let hhmmss: String + public let driveTimeMinutes: Int + public let idleTimeMinutes: Int + public let distance: Double + public let avgSpeed: Double + public let maxSpeed: Double + + public init(date: String, hhmmss: String, driveTimeMinutes: Int, idleTimeMinutes: Int, distance: Double, avgSpeed: Double, maxSpeed: Double) { + self.date = date + self.hhmmss = hhmmss + self.driveTimeMinutes = driveTimeMinutes + self.idleTimeMinutes = idleTimeMinutes + self.distance = distance + self.avgSpeed = avgSpeed + self.maxSpeed = maxSpeed + } +} From 7ee6de38e9cc5aa3c5188d9f0a6efc79f9e5e469 Mon Sep 17 00:00:00 2001 From: Chetan Date: Fri, 24 Jul 2026 17:37:22 +0100 Subject: [PATCH 3/4] Add EV Trip Info fetching to bbcli testing tool --- Sources/BBCLI/main.swift | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Sources/BBCLI/main.swift b/Sources/BBCLI/main.swift index bc1cc9e..a658d5f 100644 --- a/Sources/BBCLI/main.swift +++ b/Sources/BBCLI/main.swift @@ -456,6 +456,36 @@ func fetchEVTripDetails(state: CLIState) async throws { } } +@MainActor +func fetchEVTripInfo(state: CLIState) async throws { + guard let vehicle = selectVehicle(state: state) else { return } + guard let token = state.authToken else { + throw APIError(message: "Not logged in") + } + + guard let client = state.client else { + throw APIError(message: "No API client initialized") + } + + let dateString = prompt("Enter date (yyyyMMdd): ") + guard !dateString.isEmpty else { return } + + printSubheader("Fetching EV Trip Info for \(vehicle.model) on \(dateString)") + + let trips = try await client.fetchEVTripInfo(for: vehicle, authToken: token, dateString: dateString) ?? [] + + printSuccess("Found \(trips.count) individual trip(s) for the day") + + for (index, info) in trips.enumerated() { + print("\n[\(index + 1)] Time: \(info.hhmmss)") + print(" Distance: \(info.distance)") + print(" Drive Time: \(info.driveTimeMinutes) min") + print(" Idle Time: \(info.idleTimeMinutes) min") + print(" Avg Speed: \(info.avgSpeed)") + print(" Max Speed: \(info.maxSpeed)") + } +} + // MARK: - Interactive Menu func showMenu() { @@ -474,6 +504,7 @@ func showMenu() { 8. Stop Charge 9. Set Charge Limits 10. Fetch EV Trip Details + 11. Fetch EV Trip Info 0. Exit """) @@ -520,6 +551,8 @@ func runInteractiveLoop(state: CLIState) async { ) case "10": try await fetchEVTripDetails(state: state) + case "11": + try await fetchEVTripInfo(state: state) case "0", "q", "quit", "exit": print("\nGoodbye!") return From 2b5d20645b8f5a8edf1e79434f80b852bb8fc6fe Mon Sep 17 00:00:00 2001 From: Chetan Date: Fri, 24 Jul 2026 17:47:10 +0100 Subject: [PATCH 4/4] Fix: Correct EV Trip Info parsing to read from nested tripList array --- .../HyundaiEuropeAPIClient+Parsing.swift | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift index 1dec411..8f7fa6c 100644 --- a/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift +++ b/Sources/BetterBlueKit/API/HyundaiEurope/HyundaiEuropeAPIClient+Parsing.swift @@ -440,24 +440,35 @@ extension HyundaiEuropeAPIClient { throw APIError(message: "Failed to parse EU individual trips", apiName: apiName) } - return dayTripList.map { tripData in - let date = tripData["tripDate"] as? String ?? tripData["date"] as? String ?? "" - let hhmmss = tripData["tripTime"] as? String ?? tripData["hhmmss"] as? String ?? "" - let driveTime = tripData["tripDrvTime"] as? Int ?? tripData["drive_time"] as? Int ?? 0 - let idleTime = tripData["tripIdleTime"] as? Int ?? tripData["idle_time"] as? Int ?? 0 - let distance = getDoubleFromJson(from: tripData, key: "tripDistance") - let avgSpeed = getDoubleFromJson(from: tripData, key: "tripAvgSpeed") - let maxSpeed = getDoubleFromJson(from: tripData, key: "tripMaxSpeed") - - return EVTripInfo( - date: date, - hhmmss: hhmmss, - driveTimeMinutes: driveTime, - idleTimeMinutes: idleTime, - distance: distance, - avgSpeed: avgSpeed, - maxSpeed: maxSpeed - ) + var allTrips: [EVTripInfo] = [] + + for dayTrip in dayTripList { + let date = dayTrip["tripDay"] as? String ?? dayTrip["date"] as? String ?? "" + + guard let tripList = dayTrip["tripList"] as? [[String: Any]] else { + continue + } + + for tripData in tripList { + let hhmmss = tripData["tripTime"] as? String ?? tripData["hhmmss"] as? String ?? "" + let driveTime = tripData["tripDrvTime"] as? Int ?? tripData["drive_time"] as? Int ?? 0 + let idleTime = tripData["tripIdleTime"] as? Int ?? tripData["idle_time"] as? Int ?? 0 + let distance = getDoubleFromJson(from: tripData, key: "tripDist") + let avgSpeed = getDoubleFromJson(from: tripData, key: "tripAvgSpeed") + let maxSpeed = getDoubleFromJson(from: tripData, key: "tripMaxSpeed") + + allTrips.append(EVTripInfo( + date: date, + hhmmss: hhmmss, + driveTimeMinutes: driveTime, + idleTimeMinutes: idleTime, + distance: distance, + avgSpeed: avgSpeed, + maxSpeed: maxSpeed + )) + } } + + return allTrips } }